Merge remote-tracking branch 'origin/rhan/surface-updates' into rhan/emittance

This commit is contained in:
Roy Han
2026-03-12 11:08:32 -07:00
437 changed files with 65096 additions and 43445 deletions
+8
View File
@@ -0,0 +1,8 @@
# Paths are matched exactly, relative to the repository root.
# Keep this list short and limited to intentional large checked-in assets.
.github/codex-cli-splash.png
MODULE.bazel.lock
codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json
codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json
codex-rs/tui/tests/fixtures/oss-story.jsonl
+29
View File
@@ -0,0 +1,29 @@
name: blob-size-policy
on:
pull_request: {}
jobs:
check:
name: Blob size policy
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Determine PR comparison range
id: range
shell: bash
run: |
set -euo pipefail
echo "base=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT"
echo "head=$(git rev-parse HEAD^2)" >> "$GITHUB_OUTPUT"
- name: Check changed blob sizes
run: |
python3 scripts/check_blob_size.py \
--base "${{ steps.range.outputs.base }}" \
--head "${{ steps.range.outputs.head }}" \
--max-bytes 512000 \
--allowlist .github/blob-size-allowlist.txt
+17 -2
View File
@@ -1442,6 +1442,8 @@ dependencies = [
"codex-utils-pty",
"core_test_support",
"futures",
"opentelemetry",
"opentelemetry_sdk",
"owo-colors",
"pretty_assertions",
"reqwest",
@@ -1457,6 +1459,7 @@ dependencies = [
"tokio-util",
"toml 0.9.11+spec-1.1.0",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"uuid",
"wiremock",
@@ -1616,6 +1619,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-connectors",
"codex-core",
"codex-git",
"codex-utils-cargo-bin",
@@ -1625,7 +1629,6 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"urlencoding",
]
[[package]]
@@ -1787,6 +1790,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "codex-connectors"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-app-server-protocol",
"pretty_assertions",
"serde",
"tokio",
"urlencoding",
]
[[package]]
name = "codex-core"
version = "0.0.0"
@@ -1811,6 +1826,7 @@ dependencies = [
"codex-async-utils",
"codex-client",
"codex-config",
"codex-connectors",
"codex-execpolicy",
"codex-file-search",
"codex-git",
@@ -2442,7 +2458,6 @@ name = "codex-stdio-to-uds"
version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"codex-utils-cargo-bin",
"pretty_assertions",
"tempfile",
+2
View File
@@ -16,6 +16,7 @@ members = [
"cloud-tasks",
"cloud-tasks-client",
"cli",
"connectors",
"config",
"shell-command",
"shell-escalation",
@@ -98,6 +99,7 @@ codex-chatgpt = { path = "chatgpt" }
codex-cli = { path = "cli" }
codex-client = { path = "codex-client" }
codex-cloud-requirements = { path = "cloud-requirements" }
codex-connectors = { path = "connectors" }
codex-config = { path = "config" }
codex-core = { path = "core" }
codex-exec = { path = "exec" }
@@ -813,7 +813,7 @@
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).",
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
"type": "string"
},
@@ -1484,6 +1484,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -1501,6 +1507,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -1590,6 +1637,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -3059,6 +3059,10 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"model": {
"description": "Model requested for the spawned agent.",
"type": "string"
},
"new_agent_nickname": {
"description": "Optional nickname assigned to the new agent.",
"type": [
@@ -3088,6 +3092,14 @@
"description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.",
"type": "string"
},
"reasoning_effort": {
"allOf": [
{
"$ref": "#/definitions/ReasoningEffort"
}
],
"description": "Reasoning effort requested for the spawned agent."
},
"sender_thread_id": {
"allOf": [
{
@@ -3114,7 +3126,9 @@
},
"required": [
"call_id",
"model",
"prompt",
"reasoning_effort",
"sender_thread_id",
"status",
"type"
@@ -5296,6 +5310,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -5313,6 +5333,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -5402,6 +5463,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -9314,6 +9410,10 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"model": {
"description": "Model requested for the spawned agent.",
"type": "string"
},
"new_agent_nickname": {
"description": "Optional nickname assigned to the new agent.",
"type": [
@@ -9343,6 +9443,14 @@
"description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.",
"type": "string"
},
"reasoning_effort": {
"allOf": [
{
"$ref": "#/definitions/ReasoningEffort"
}
],
"description": "Reasoning effort requested for the spawned agent."
},
"sender_thread_id": {
"allOf": [
{
@@ -9369,7 +9477,9 @@
},
"required": [
"call_id",
"model",
"prompt",
"reasoning_effort",
"sender_thread_id",
"status",
"type"
@@ -1603,6 +1603,18 @@
],
"type": "object"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"ReasoningSummaryPartAddedNotification": {
"properties": {
"itemId": {
@@ -2480,6 +2492,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -2487,6 +2506,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -4422,6 +4422,10 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"model": {
"description": "Model requested for the spawned agent.",
"type": "string"
},
"new_agent_nickname": {
"description": "Optional nickname assigned to the new agent.",
"type": [
@@ -4451,6 +4455,14 @@
"description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.",
"type": "string"
},
"reasoning_effort": {
"allOf": [
{
"$ref": "#/definitions/v2/ReasoningEffort"
}
],
"description": "Reasoning effort requested for the spawned agent."
},
"sender_thread_id": {
"allOf": [
{
@@ -4477,7 +4489,9 @@
},
"required": [
"call_id",
"model",
"prompt",
"reasoning_effort",
"sender_thread_id",
"status",
"type"
@@ -5423,7 +5437,7 @@
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).",
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
"type": "string"
},
@@ -12891,18 +12905,12 @@
"type": "array"
},
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginAuthPolicy"
},
{
"type": "null"
}
]
"$ref": "#/definitions/v2/PluginAuthPolicy"
}
},
"required": [
"appsNeedingAuth"
"appsNeedingAuth",
"authPolicy"
],
"title": "PluginInstallResponse",
"type": "object"
@@ -13100,14 +13108,7 @@
"PluginSummary": {
"properties": {
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginAuthPolicy"
},
{
"type": "null"
}
]
"$ref": "#/definitions/v2/PluginAuthPolicy"
},
"enabled": {
"type": "boolean"
@@ -13116,14 +13117,7 @@
"type": "string"
},
"installPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginInstallPolicy"
},
{
"type": "null"
}
]
"$ref": "#/definitions/v2/PluginInstallPolicy"
},
"installed": {
"type": "boolean"
@@ -13146,8 +13140,10 @@
}
},
"required": [
"authPolicy",
"enabled",
"id",
"installPolicy",
"installed",
"name",
"source"
@@ -13911,6 +13907,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -13928,6 +13930,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -14017,6 +14060,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -15935,6 +16013,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -15942,6 +16027,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/v2/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -6224,6 +6224,10 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"model": {
"description": "Model requested for the spawned agent.",
"type": "string"
},
"new_agent_nickname": {
"description": "Optional nickname assigned to the new agent.",
"type": [
@@ -6253,6 +6257,14 @@
"description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.",
"type": "string"
},
"reasoning_effort": {
"allOf": [
{
"$ref": "#/definitions/ReasoningEffort"
}
],
"description": "Reasoning effort requested for the spawned agent."
},
"sender_thread_id": {
"allOf": [
{
@@ -6279,7 +6291,9 @@
},
"required": [
"call_id",
"model",
"prompt",
"reasoning_effort",
"sender_thread_id",
"status",
"type"
@@ -7705,7 +7719,7 @@
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).",
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
"type": "string"
},
@@ -9154,18 +9168,12 @@
"type": "array"
},
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
"$ref": "#/definitions/PluginAuthPolicy"
}
},
"required": [
"appsNeedingAuth"
"appsNeedingAuth",
"authPolicy"
],
"title": "PluginInstallResponse",
"type": "object"
@@ -9363,14 +9371,7 @@
"PluginSummary": {
"properties": {
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
"$ref": "#/definitions/PluginAuthPolicy"
},
"enabled": {
"type": "boolean"
@@ -9379,14 +9380,7 @@
"type": "string"
},
"installPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicy"
},
{
"type": "null"
}
]
"$ref": "#/definitions/PluginInstallPolicy"
},
"installed": {
"type": "boolean"
@@ -9409,8 +9403,10 @@
}
},
"required": [
"authPolicy",
"enabled",
"id",
"installPolicy",
"installed",
"name",
"source"
@@ -10436,6 +10432,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -10453,6 +10455,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -10542,6 +10585,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -13617,6 +13695,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -13624,6 +13709,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -31,7 +31,7 @@
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).",
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
"type": "string"
},
@@ -389,6 +389,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"TextElement": {
"properties": {
"byteRange": {
@@ -856,6 +868,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -863,6 +882,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -389,6 +389,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"TextElement": {
"properties": {
"byteRange": {
@@ -856,6 +868,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -863,6 +882,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -45,18 +45,12 @@
"type": "array"
},
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
"$ref": "#/definitions/PluginAuthPolicy"
}
},
"required": [
"appsNeedingAuth"
"appsNeedingAuth",
"authPolicy"
],
"title": "PluginInstallResponse",
"type": "object"
@@ -170,14 +170,7 @@
"PluginSummary": {
"properties": {
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
"$ref": "#/definitions/PluginAuthPolicy"
},
"enabled": {
"type": "boolean"
@@ -186,14 +179,7 @@
"type": "string"
},
"installPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicy"
},
{
"type": "null"
}
]
"$ref": "#/definitions/PluginInstallPolicy"
},
"installed": {
"type": "boolean"
@@ -216,8 +202,10 @@
}
},
"required": [
"authPolicy",
"enabled",
"id",
"installPolicy",
"installed",
"name",
"source"
@@ -506,6 +506,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -523,6 +529,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -612,6 +659,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -503,6 +503,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"TextElement": {
"properties": {
"byteRange": {
@@ -970,6 +982,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -977,6 +996,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -1454,6 +1454,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -1461,6 +1468,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -526,6 +526,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -1208,6 +1220,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -1215,6 +1234,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -526,6 +526,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -1208,6 +1220,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -1215,6 +1234,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -526,6 +526,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -1208,6 +1220,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -1215,6 +1234,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -564,6 +564,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -581,6 +587,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -670,6 +717,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -1454,6 +1454,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -1461,6 +1468,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -526,6 +526,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -1208,6 +1220,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -1215,6 +1234,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -1454,6 +1454,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -1461,6 +1468,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -526,6 +526,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -1208,6 +1220,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -1215,6 +1234,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -526,6 +526,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -1208,6 +1220,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -1215,6 +1234,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -503,6 +503,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"TextElement": {
"properties": {
"byteRange": {
@@ -970,6 +982,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -977,6 +996,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -503,6 +503,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"TextElement": {
"properties": {
"byteRange": {
@@ -970,6 +982,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -977,6 +996,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -503,6 +503,18 @@
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"TextElement": {
"properties": {
"byteRange": {
@@ -970,6 +982,13 @@
}
]
},
"model": {
"description": "Model requested for the spawned agent, when applicable.",
"type": [
"string",
"null"
]
},
"prompt": {
"description": "Prompt text sent as part of the collab tool call, when available.",
"type": [
@@ -977,6 +996,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Reasoning effort requested for the spawned agent, when applicable."
},
"receiverThreadIds": {
"description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.",
"items": {
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AgentStatus } from "./AgentStatus";
import type { ReasoningEffort } from "./ReasoningEffort";
import type { ThreadId } from "./ThreadId";
export type CollabAgentSpawnEndEvent = {
@@ -30,6 +31,14 @@ new_agent_role?: string | null,
* beginning.
*/
prompt: string,
/**
* Model requested for the spawned agent.
*/
model: string,
/**
* Reasoning effort requested for the spawned agent.
*/
reasoning_effort: ReasoningEffort,
/**
* Last known status of the new agent reported to the sender agent.
*/
@@ -12,6 +12,6 @@ export type InitializeCapabilities = {
experimentalApi: boolean,
/**
* Exact notification method names that should be suppressed for this
* connection (for example `codex/event/session_configured`).
* connection (for example `thread/started`).
*/
optOutNotificationMethods?: Array<string> | null, };
@@ -16,4 +16,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array<Con
/**
* Set when using the Responses API.
*/
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, arguments: string, call_id: string, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
@@ -4,4 +4,4 @@
import type { AppSummary } from "./AppSummary";
import type { PluginAuthPolicy } from "./PluginAuthPolicy";
export type PluginInstallResponse = { authPolicy: PluginAuthPolicy | null, appsNeedingAuth: Array<AppSummary>, };
export type PluginInstallResponse = { authPolicy: PluginAuthPolicy, appsNeedingAuth: Array<AppSummary>, };
@@ -6,4 +6,4 @@ import type { PluginInstallPolicy } from "./PluginInstallPolicy";
import type { PluginInterface } from "./PluginInterface";
import type { PluginSource } from "./PluginSource";
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy | null, authPolicy: PluginAuthPolicy | null, interface: PluginInterface | null, };
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, interface: PluginInterface | null, };
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { MessagePhase } from "../MessagePhase";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { JsonValue } from "../serde_json/JsonValue";
import type { CollabAgentState } from "./CollabAgentState";
import type { CollabAgentTool } from "./CollabAgentTool";
@@ -83,6 +84,14 @@ receiverThreadIds: Array<string>,
* Prompt text sent as part of the collab tool call, when available.
*/
prompt: string | null,
/**
* Model requested for the spawned agent, when applicable.
*/
model: string | null,
/**
* Reasoning effort requested for the spawned agent, when applicable.
*/
reasoningEffort: ReasoningEffort | null,
/**
* Last known status of the target agents, when available.
*/
+22 -1
View File
@@ -14,7 +14,28 @@ pub use export::generate_types;
pub use jsonrpc_lite::*;
pub use protocol::common::*;
pub use protocol::thread_history::*;
pub use protocol::v1::*;
pub use protocol::v1::ApplyPatchApprovalParams;
pub use protocol::v1::ApplyPatchApprovalResponse;
pub use protocol::v1::ClientInfo;
pub use protocol::v1::ConversationGitInfo;
pub use protocol::v1::ConversationSummary;
pub use protocol::v1::ExecCommandApprovalParams;
pub use protocol::v1::ExecCommandApprovalResponse;
pub use protocol::v1::GetAuthStatusParams;
pub use protocol::v1::GetAuthStatusResponse;
pub use protocol::v1::GetConversationSummaryParams;
pub use protocol::v1::GetConversationSummaryResponse;
pub use protocol::v1::GitDiffToRemoteParams;
pub use protocol::v1::GitDiffToRemoteResponse;
pub use protocol::v1::InitializeCapabilities;
pub use protocol::v1::InitializeParams;
pub use protocol::v1::InitializeResponse;
pub use protocol::v1::InterruptConversationResponse;
pub use protocol::v1::LoginApiKeyParams;
pub use protocol::v1::Profile;
pub use protocol::v1::SandboxSettings;
pub use protocol::v1::Tools;
pub use protocol::v1::UserSavedConfig;
pub use protocol::v2::*;
pub use schema_fixtures::SchemaFixtureOptions;
#[doc(hidden)]
@@ -955,7 +955,7 @@ mod tests {
capabilities: Some(v1::InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: Some(vec![
"codex/event/session_configured".to_string(),
"thread/started".to_string(),
"item/agentMessage/delta".to_string(),
]),
}),
@@ -975,7 +975,7 @@ mod tests {
"capabilities": {
"experimentalApi": true,
"optOutNotificationMethods": [
"codex/event/session_configured",
"thread/started",
"item/agentMessage/delta"
]
}
@@ -1000,7 +1000,7 @@ mod tests {
"capabilities": {
"experimentalApi": true,
"optOutNotificationMethods": [
"codex/event/session_configured",
"thread/started",
"item/agentMessage/delta"
]
}
@@ -1020,7 +1020,7 @@ mod tests {
capabilities: Some(v1::InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: Some(vec![
"codex/event/session_configured".to_string(),
"thread/started".to_string(),
"item/agentMessage/delta".to_string(),
]),
}),
@@ -577,6 +577,8 @@ impl ThreadHistoryBuilder {
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: Vec::new(),
prompt: Some(payload.prompt.clone()),
model: Some(payload.model.clone()),
reasoning_effort: Some(payload.reasoning_effort),
agents_states: HashMap::new(),
metadata: None,
};
@@ -611,6 +613,8 @@ impl ThreadHistoryBuilder {
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids,
prompt: Some(payload.prompt.clone()),
model: Some(payload.model.clone()),
reasoning_effort: Some(payload.reasoning_effort),
agents_states,
metadata: None,
});
@@ -627,6 +631,8 @@ impl ThreadHistoryBuilder {
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![payload.receiver_thread_id.to_string()],
prompt: Some(payload.prompt.clone()),
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
metadata: None,
};
@@ -650,6 +656,8 @@ impl ThreadHistoryBuilder {
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id.clone()],
prompt: Some(payload.prompt.clone()),
model: None,
reasoning_effort: None,
agents_states: [(receiver_id, received_status)].into_iter().collect(),
metadata: None,
});
@@ -670,6 +678,8 @@ impl ThreadHistoryBuilder {
.map(ToString::to_string)
.collect(),
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
metadata: None,
};
@@ -704,6 +714,8 @@ impl ThreadHistoryBuilder {
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids,
prompt: None,
model: None,
reasoning_effort: None,
agents_states,
metadata: None,
});
@@ -720,6 +732,8 @@ impl ThreadHistoryBuilder {
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![payload.receiver_thread_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
metadata: None,
};
@@ -745,6 +759,8 @@ impl ThreadHistoryBuilder {
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id],
prompt: None,
model: None,
reasoning_effort: None,
agents_states,
metadata: None,
});
@@ -761,6 +777,8 @@ impl ThreadHistoryBuilder {
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![payload.receiver_thread_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
metadata: None,
};
@@ -789,6 +807,8 @@ impl ThreadHistoryBuilder {
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id],
prompt: None,
model: None,
reasoning_effort: None,
agents_states,
metadata: None,
});
@@ -2396,6 +2416,8 @@ mod tests {
sender_thread_id: "00000000-0000-0000-0000-000000000001".into(),
receiver_thread_ids: vec!["00000000-0000-0000-0000-000000000002".into()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: [(
"00000000-0000-0000-0000-000000000002".into(),
CollabAgentState {
@@ -2410,6 +2432,64 @@ mod tests {
);
}
#[test]
fn reconstructs_collab_spawn_end_item_with_model_metadata() {
let sender_thread_id = ThreadId::try_from("00000000-0000-0000-0000-000000000001")
.expect("valid sender thread id");
let spawned_thread_id = ThreadId::try_from("00000000-0000-0000-0000-000000000002")
.expect("valid receiver thread id");
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
message: "spawn agent".into(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
}),
EventMsg::CollabAgentSpawnEnd(codex_protocol::protocol::CollabAgentSpawnEndEvent {
call_id: "spawn-1".into(),
sender_thread_id,
new_thread_id: Some(spawned_thread_id),
new_agent_nickname: Some("Scout".into()),
new_agent_role: Some("explorer".into()),
prompt: "inspect the repo".into(),
model: "gpt-5.4-mini".into(),
reasoning_effort: codex_protocol::openai_models::ReasoningEffort::Medium,
status: AgentStatus::Running,
}),
];
let items = events
.into_iter()
.map(RolloutItem::EventMsg)
.collect::<Vec<_>>();
let turns = build_turns_from_rollout_items(&items);
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].items.len(), 2);
assert_eq!(
turns[0].items[1],
ThreadItem::CollabAgentToolCall {
id: "spawn-1".into(),
tool: CollabAgentTool::SpawnAgent,
status: CollabAgentToolCallStatus::Completed,
sender_thread_id: "00000000-0000-0000-0000-000000000001".into(),
receiver_thread_ids: vec!["00000000-0000-0000-0000-000000000002".into()],
prompt: Some("inspect the repo".into()),
model: Some("gpt-5.4-mini".into()),
reasoning_effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium),
agents_states: [(
"00000000-0000-0000-0000-000000000002".into(),
CollabAgentState {
status: crate::protocol::v2::CollabAgentStatus::Running,
message: None,
},
)]
.into_iter()
.collect(),
metadata: None,
}
);
}
#[test]
fn rollback_failed_error_does_not_mark_turn_failed() {
let events = vec![
+1 -363
View File
@@ -5,28 +5,21 @@ use codex_protocol::ThreadId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::user_input::ByteRange as CoreByteRange;
use codex_protocol::user_input::TextElement as CoreTextElement;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
use uuid::Uuid;
// Reuse shared types defined in `common.rs`.
use crate::protocol::common::AuthMode;
use crate::protocol::common::GitSha;
@@ -54,7 +47,7 @@ pub struct InitializeCapabilities {
#[serde(default)]
pub experimental_api: bool,
/// Exact notification method names that should be suppressed for this
/// connection (for example `codex/event/session_configured`).
/// connection (for example `thread/started`).
#[ts(optional = nullable)]
pub opt_out_notification_methods: Option<Vec<String>>,
}
@@ -65,51 +58,6 @@ pub struct InitializeResponse {
pub user_agent: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct NewConversationParams {
pub model: Option<String>,
pub model_provider: Option<String>,
pub profile: Option<String>,
pub cwd: Option<String>,
pub approval_policy: Option<AskForApproval>,
pub sandbox: Option<SandboxMode>,
pub config: Option<HashMap<String, serde_json::Value>>,
pub base_instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub developer_instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compact_prompt: Option<String>,
pub include_apply_patch_tool: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct NewConversationResponse {
pub conversation_id: ThreadId,
pub model: String,
pub reasoning_effort: Option<ReasoningEffort>,
pub rollout_path: PathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ResumeConversationResponse {
pub conversation_id: ThreadId,
pub model: String,
pub initial_messages: Option<Vec<EventMsg>>,
pub rollout_path: PathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ForkConversationResponse {
pub conversation_id: ThreadId,
pub model: String,
pub initial_messages: Option<Vec<EventMsg>>,
pub rollout_path: PathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(untagged)]
pub enum GetConversationSummaryParams {
@@ -129,14 +77,6 @@ pub struct GetConversationSummaryResponse {
pub summary: ConversationSummary,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ListConversationsParams {
pub page_size: Option<usize>,
pub cursor: Option<String>,
pub model_providers: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ConversationSummary {
@@ -160,70 +100,12 @@ pub struct ConversationGitInfo {
pub origin_url: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ListConversationsResponse {
pub items: Vec<ConversationSummary>,
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ResumeConversationParams {
pub path: Option<PathBuf>,
pub conversation_id: Option<ThreadId>,
pub history: Option<Vec<ResponseItem>>,
pub overrides: Option<NewConversationParams>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ForkConversationParams {
pub path: Option<PathBuf>,
pub conversation_id: Option<ThreadId>,
pub overrides: Option<NewConversationParams>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct AddConversationSubscriptionResponse {
#[schemars(with = "String")]
pub subscription_id: Uuid,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ArchiveConversationParams {
pub conversation_id: ThreadId,
pub rollout_path: PathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ArchiveConversationResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct RemoveConversationSubscriptionResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct LoginApiKeyParams {
pub api_key: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct LoginApiKeyResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct LoginChatGptResponse {
#[schemars(with = "String")]
pub login_id: Uuid,
pub auth_url: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct GitDiffToRemoteResponse {
@@ -272,31 +154,12 @@ pub struct ExecCommandApprovalResponse {
pub decision: ReviewDecision,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct CancelLoginChatGptParams {
#[schemars(with = "String")]
pub login_id: Uuid,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct GitDiffToRemoteParams {
pub cwd: PathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct CancelLoginChatGptResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct LogoutChatGptParams {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct LogoutChatGptResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetAuthStatusParams {
@@ -313,14 +176,6 @@ pub struct ExecOneOffCommandParams {
pub sandbox_policy: Option<SandboxPolicy>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ExecOneOffCommandResponse {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetAuthStatusResponse {
@@ -329,35 +184,6 @@ pub struct GetAuthStatusResponse {
pub requires_openai_auth: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetUserAgentResponse {
pub user_agent: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct UserInfoResponse {
pub alleged_user_email: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetUserSavedConfigResponse {
pub config: UserSavedConfig,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct SetDefaultModelParams {
pub model: Option<String>,
pub reasoning_effort: Option<ReasoningEffort>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct SetDefaultModelResponse {}
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct UserSavedConfig {
@@ -404,196 +230,8 @@ pub struct SandboxSettings {
pub exclude_slash_tmp: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct SendUserMessageParams {
pub conversation_id: ThreadId,
pub items: Vec<InputItem>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct SendUserTurnParams {
pub conversation_id: ThreadId,
pub items: Vec<InputItem>,
pub cwd: PathBuf,
pub approval_policy: AskForApproval,
pub sandbox_policy: SandboxPolicy,
pub model: String,
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
pub service_tier: Option<Option<ServiceTier>>,
pub effort: Option<ReasoningEffort>,
pub summary: ReasoningSummary,
/// Optional JSON Schema used to constrain the final assistant message for
/// this turn.
pub output_schema: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct SendUserTurnResponse {}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn send_user_turn_params_preserve_explicit_null_service_tier() {
let params = SendUserTurnParams {
conversation_id: ThreadId::new(),
items: vec![],
cwd: PathBuf::from("/tmp"),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: "gpt-4.1".to_string(),
service_tier: Some(None),
effort: None,
summary: ReasoningSummary::Auto,
output_schema: None,
};
let serialized = serde_json::to_value(&params).expect("params should serialize");
assert_eq!(
serialized.get("serviceTier"),
Some(&serde_json::Value::Null)
);
let roundtrip: SendUserTurnParams =
serde_json::from_value(serialized).expect("params should deserialize");
assert_eq!(roundtrip.service_tier, Some(None));
let without_override = SendUserTurnParams {
conversation_id: ThreadId::new(),
items: vec![],
cwd: PathBuf::from("/tmp"),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: "gpt-4.1".to_string(),
service_tier: None,
effort: None,
summary: ReasoningSummary::Auto,
output_schema: None,
};
let serialized_without_override =
serde_json::to_value(&without_override).expect("params should serialize");
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct InterruptConversationParams {
pub conversation_id: ThreadId,
}
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct InterruptConversationResponse {
pub abort_reason: TurnAbortReason,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct SendUserMessageResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct AddConversationListenerParams {
pub conversation_id: ThreadId,
#[serde(default)]
pub experimental_raw_events: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct RemoveConversationListenerParams {
#[schemars(with = "String")]
pub subscription_id: Uuid,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[serde(tag = "type", content = "data")]
pub enum InputItem {
Text {
text: String,
/// UI-defined spans within `text` used to render or persist special elements.
#[serde(default)]
text_elements: Vec<V1TextElement>,
},
Image {
image_url: String,
},
LocalImage {
path: PathBuf,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename = "ByteRange")]
pub struct V1ByteRange {
/// Start byte offset (inclusive) within the UTF-8 text buffer.
pub start: usize,
/// End byte offset (exclusive) within the UTF-8 text buffer.
pub end: usize,
}
impl From<CoreByteRange> for V1ByteRange {
fn from(value: CoreByteRange) -> Self {
Self {
start: value.start,
end: value.end,
}
}
}
impl From<V1ByteRange> for CoreByteRange {
fn from(value: V1ByteRange) -> Self {
Self {
start: value.start,
end: value.end,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename = "TextElement")]
pub struct V1TextElement {
/// Byte range in the parent `text` buffer that this element occupies.
pub byte_range: V1ByteRange,
/// Optional human-readable placeholder for the element, displayed in the UI.
pub placeholder: Option<String>,
}
impl From<CoreTextElement> for V1TextElement {
fn from(value: CoreTextElement) -> Self {
Self {
byte_range: value.byte_range.into(),
placeholder: value._placeholder_for_conversion_only().map(str::to_string),
}
}
}
impl From<V1TextElement> for CoreTextElement {
fn from(value: V1TextElement) -> Self {
Self::new(value.byte_range.into(), value.placeholder)
}
}
impl InputItem {
pub fn text_char_count(&self) -> usize {
match self {
InputItem::Text { text, .. } => text.chars().count(),
InputItem::Image { .. } | InputItem::LocalImage { .. } => 0,
}
}
}
@@ -3089,8 +3089,8 @@ pub struct PluginSummary {
pub source: PluginSource,
pub installed: bool,
pub enabled: bool,
pub install_policy: Option<PluginInstallPolicy>,
pub auth_policy: Option<PluginAuthPolicy>,
pub install_policy: PluginInstallPolicy,
pub auth_policy: PluginAuthPolicy,
pub interface: Option<PluginInterface>,
}
@@ -3151,7 +3151,7 @@ pub struct PluginInstallParams {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginInstallResponse {
pub auth_policy: Option<PluginAuthPolicy>,
pub auth_policy: PluginAuthPolicy,
pub apps_needing_auth: Vec<AppSummary>,
}
@@ -4013,6 +4013,10 @@ pub enum ThreadItem {
receiver_thread_ids: Vec<String>,
/// Prompt text sent as part of the collab tool call, when available.
prompt: Option<String>,
/// Model requested for the spawned agent, when applicable.
model: Option<String>,
/// Reasoning effort requested for the spawned agent, when applicable.
reasoning_effort: Option<ReasoningEffort>,
/// Last known status of the target agents, when available.
agents_states: HashMap<String, CollabAgentState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -88,20 +88,6 @@ use url::Url;
use uuid::Uuid;
const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[
// Legacy codex/event (v1-style) deltas.
"codex/event/agent_message_content_delta",
"codex/event/agent_message_delta",
"codex/event/agent_reasoning_delta",
"codex/event/reasoning_content_delta",
"codex/event/reasoning_raw_content_delta",
"codex/event/exec_command_output_delta",
// Other legacy events.
"codex/event/exec_approval_request",
"codex/event/exec_command_begin",
"codex/event/exec_command_end",
"codex/event/exec_output",
"codex/event/item_started",
"codex/event/item_completed",
// v2 item deltas.
"command/exec/outputDelta",
"item/agentMessage/delta",
+3
View File
@@ -79,6 +79,8 @@ axum = { workspace = true, default-features = false, features = [
] }
core_test_support = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
pretty_assertions = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"] }
rmcp = { workspace = true, default-features = false, features = [
@@ -88,5 +90,6 @@ rmcp = { workspace = true, default-features = false, features = [
] }
serial_test = { workspace = true }
tokio-tungstenite = { workspace = true }
tracing-opentelemetry = { workspace = true }
wiremock = { workspace = true }
shlex = { workspace = true }
+5 -5
View File
@@ -115,7 +115,7 @@ Example with notification opt-out:
"capabilities": {
"experimentalApi": true,
"optOutNotificationMethods": [
"codex/event/session_configured",
"thread/started",
"item/agentMessage/delta"
]
}
@@ -157,13 +157,13 @@ Example with notification opt-out:
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including marketplace install/auth policy metadata. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
- `skills/changed` — notification emitted when watched local skill files change.
- `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**).
- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**).
- `app/list` — list available apps.
- `skills/config/write` — write user-level skill config by path.
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, and return the plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
- `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**).
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
@@ -722,12 +722,12 @@ Clients can suppress specific notifications per connection by sending exact meth
- Exact-match only: `item/agentMessage/delta` suppresses only that method.
- Unknown method names are ignored.
- Applies to both legacy (`codex/event/*`) and v2 (`thread/*`, `turn/*`, `item/*`, etc.) notifications.
- Applies to app-server typed notifications such as `thread/*`, `turn/*`, `item/*`, and `rawResponseItem/*`.
- Does not apply to requests/responses/errors.
Examples:
- Opt out of legacy session setup event: `codex/event/session_configured`
- Opt out of thread lifecycle notifications: `thread/started`
- Opt out of streamed agent text deltas: `item/agentMessage/delta`
### Fuzzy file search events (experimental)
+81 -70
View File
@@ -27,50 +27,29 @@ pub(crate) fn request_span(
connection_id: ConnectionId,
session: &ConnectionSessionState,
) -> Span {
let span = info_span!(
"app_server.request",
otel.kind = "server",
otel.name = request.method.as_str(),
rpc.system = "jsonrpc",
rpc.method = request.method.as_str(),
rpc.transport = transport_name(transport),
rpc.request_id = ?request.id,
app_server.connection_id = ?connection_id,
app_server.api_version = "v2",
app_server.client_name = field::Empty,
app_server.client_version = field::Empty,
let initialize_client_info = initialize_client_info(request);
let method = request.method.as_str();
let span = app_server_request_span_template(
method,
transport_name(transport),
&request.id,
connection_id,
);
let initialize_client_info = initialize_client_info(request);
if let Some(client_name) = client_name(initialize_client_info.as_ref(), session) {
span.record("app_server.client_name", client_name);
}
if let Some(client_version) = client_version(initialize_client_info.as_ref(), session) {
span.record("app_server.client_version", client_version);
}
record_client_info(
&span,
client_name(initialize_client_info.as_ref(), session),
client_version(initialize_client_info.as_ref(), session),
);
if let Some(traceparent) = request
.trace
.as_ref()
.and_then(|trace| trace.traceparent.as_deref())
{
let trace = W3cTraceContext {
traceparent: Some(traceparent.to_string()),
tracestate: request
.trace
.as_ref()
.and_then(|value| value.tracestate.clone()),
};
if !set_parent_from_w3c_trace_context(&span, &trace) {
tracing::warn!(
rpc_method = request.method.as_str(),
rpc_request_id = ?request.id,
"ignoring invalid inbound request trace carrier"
);
}
} else if let Some(context) = traceparent_context_from_env() {
set_parent_from_context(&span, context);
}
let parent_trace = request.trace.as_ref().and_then(|trace| {
trace.traceparent.as_ref()?;
Some(W3cTraceContext {
traceparent: trace.traceparent.clone(),
tracestate: trace.tracestate.clone(),
})
});
attach_parent_context(&span, method, &request.id, parent_trace.as_ref());
span
}
@@ -86,37 +65,20 @@ pub(crate) fn typed_request_span(
session: &ConnectionSessionState,
) -> Span {
let method = request.method();
let span = info_span!(
"app_server.request",
otel.kind = "server",
otel.name = method,
rpc.system = "jsonrpc",
rpc.method = method,
rpc.transport = "in-process",
rpc.request_id = ?request.id(),
app_server.connection_id = ?connection_id,
app_server.api_version = "v2",
app_server.client_name = field::Empty,
app_server.client_version = field::Empty,
let span = app_server_request_span_template(&method, "in-process", request.id(), connection_id);
let client_info = initialize_client_info_from_typed_request(request);
record_client_info(
&span,
client_info
.map(|(client_name, _)| client_name)
.or(session.app_server_client_name.as_deref()),
client_info
.map(|(_, client_version)| client_version)
.or(session.client_version.as_deref()),
);
if let Some((client_name, client_version)) = initialize_client_info_from_typed_request(request)
{
span.record("app_server.client_name", client_name);
span.record("app_server.client_version", client_version);
} else {
if let Some(client_name) = session.app_server_client_name.as_deref() {
span.record("app_server.client_name", client_name);
}
if let Some(client_version) = session.client_version.as_deref() {
span.record("app_server.client_version", client_version);
}
}
if let Some(context) = traceparent_context_from_env() {
set_parent_from_context(&span, context);
}
attach_parent_context(&span, &method, request.id(), None);
span
}
@@ -127,6 +89,55 @@ fn transport_name(transport: AppServerTransport) -> &'static str {
}
}
fn app_server_request_span_template(
method: &str,
transport: &'static str,
request_id: &impl std::fmt::Debug,
connection_id: ConnectionId,
) -> Span {
info_span!(
"app_server.request",
otel.kind = "server",
otel.name = method,
rpc.system = "jsonrpc",
rpc.method = method,
rpc.transport = transport,
rpc.request_id = ?request_id,
app_server.connection_id = ?connection_id,
app_server.api_version = "v2",
app_server.client_name = field::Empty,
app_server.client_version = field::Empty,
)
}
fn record_client_info(span: &Span, client_name: Option<&str>, client_version: Option<&str>) {
if let Some(client_name) = client_name {
span.record("app_server.client_name", client_name);
}
if let Some(client_version) = client_version {
span.record("app_server.client_version", client_version);
}
}
fn attach_parent_context(
span: &Span,
method: &str,
request_id: &impl std::fmt::Debug,
parent_trace: Option<&W3cTraceContext>,
) {
if let Some(trace) = parent_trace {
if !set_parent_from_w3c_trace_context(span, trace) {
tracing::warn!(
rpc_method = method,
rpc_request_id = ?request_id,
"ignoring invalid inbound request trace carrier"
);
}
} else if let Some(context) = traceparent_context_from_env() {
set_parent_from_context(span, context);
}
}
fn client_name<'a>(
initialize_client_info: Option<&'a InitializeParams>,
session: &'a ConnectionSessionState,
@@ -865,6 +865,8 @@ pub(crate) async fn apply_bespoke_event_handling(
sender_thread_id: begin_event.sender_thread_id.to_string(),
receiver_thread_ids: Vec::new(),
prompt: Some(begin_event.prompt),
model: Some(begin_event.model),
reasoning_effort: Some(begin_event.reasoning_effort),
agents_states: HashMap::new(),
metadata: None,
};
@@ -903,6 +905,8 @@ pub(crate) async fn apply_bespoke_event_handling(
sender_thread_id: end_event.sender_thread_id.to_string(),
receiver_thread_ids,
prompt: Some(end_event.prompt),
model: Some(end_event.model),
reasoning_effort: Some(end_event.reasoning_effort),
agents_states,
metadata: None,
};
@@ -924,6 +928,8 @@ pub(crate) async fn apply_bespoke_event_handling(
sender_thread_id: begin_event.sender_thread_id.to_string(),
receiver_thread_ids,
prompt: Some(begin_event.prompt),
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
metadata: None,
};
@@ -951,6 +957,8 @@ pub(crate) async fn apply_bespoke_event_handling(
sender_thread_id: end_event.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id.clone()],
prompt: Some(end_event.prompt),
model: None,
reasoning_effort: None,
agents_states: [(receiver_id, received_status)].into_iter().collect(),
metadata: None,
};
@@ -976,6 +984,8 @@ pub(crate) async fn apply_bespoke_event_handling(
sender_thread_id: begin_event.sender_thread_id.to_string(),
receiver_thread_ids,
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
metadata: None,
};
@@ -1013,6 +1023,8 @@ pub(crate) async fn apply_bespoke_event_handling(
sender_thread_id: end_event.sender_thread_id.to_string(),
receiver_thread_ids,
prompt: None,
model: None,
reasoning_effort: None,
agents_states,
metadata: None,
};
@@ -1033,6 +1045,8 @@ pub(crate) async fn apply_bespoke_event_handling(
sender_thread_id: begin_event.sender_thread_id.to_string(),
receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
metadata: None,
};
@@ -1074,6 +1088,8 @@ pub(crate) async fn apply_bespoke_event_handling(
sender_thread_id: end_event.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id],
prompt: None,
model: None,
reasoning_effort: None,
agents_states,
metadata: None,
};
@@ -2534,6 +2550,8 @@ fn collab_resume_begin_item(
sender_thread_id: begin_event.sender_thread_id.to_string(),
receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
metadata: None,
}
@@ -2559,6 +2577,8 @@ fn collab_resume_end_item(end_event: codex_protocol::protocol::CollabResumeEndEv
sender_thread_id: end_event.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id],
prompt: None,
model: None,
reasoning_effort: None,
agents_states,
metadata: None,
}
@@ -2934,6 +2954,8 @@ mod tests {
sender_thread_id: event.sender_thread_id.to_string(),
receiver_thread_ids: vec![event.receiver_thread_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
metadata: None,
};
@@ -2960,6 +2982,8 @@ mod tests {
sender_thread_id: event.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id.clone()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: [(
receiver_id,
V2CollabAgentStatus::from(codex_protocol::protocol::AgentStatus::NotFound),
@@ -13,6 +13,7 @@ use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotification;
use crate::outgoing_message::RequestContext;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::thread_status::ThreadWatchManager;
use crate::thread_status::resolve_thread_status;
@@ -203,6 +204,7 @@ use codex_core::connectors::filter_disallowed_connectors;
use codex_core::connectors::merge_plugin_apps;
use codex_core::default_client::set_default_client_residency_requirement;
use codex_core::error::CodexErr;
use codex_core::error::Result as CodexResult;
use codex_core::exec::ExecExpiration;
use codex_core::exec::ExecParams;
use codex_core::exec_env::create_env;
@@ -269,6 +271,7 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use codex_protocol::user_input::UserInput as CoreInputItem;
use codex_rmcp_client::perform_oauth_login_return_url;
@@ -296,7 +299,9 @@ use tokio::sync::broadcast;
use tokio::sync::oneshot;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use toml::Value as TomlValue;
use tracing::Instrument;
use tracing::error;
use tracing::info;
use tracing::warn;
@@ -386,6 +391,7 @@ pub(crate) struct CodexMessageProcessor {
command_exec_manager: CommandExecManager,
pending_fuzzy_searches: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
fuzzy_search_sessions: Arc<Mutex<HashMap<String, FuzzyFileSearchSession>>>,
background_tasks: TaskTracker,
feedback: CodexFeedback,
log_db: Option<LogDbLayer>,
}
@@ -500,6 +506,7 @@ impl CodexMessageProcessor {
command_exec_manager: CommandExecManager::default(),
pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())),
fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())),
background_tasks: TaskTracker::new(),
feedback,
log_db,
}
@@ -620,6 +627,7 @@ impl CodexMessageProcessor {
connection_id: ConnectionId,
request: ClientRequest,
app_server_client_name: Option<String>,
request_context: RequestContext,
) {
let to_connection_request_id = |request_id| ConnectionRequestId {
connection_id,
@@ -632,8 +640,12 @@ impl CodexMessageProcessor {
}
// === v2 Thread/Turn APIs ===
ClientRequest::ThreadStart { request_id, params } => {
self.thread_start(to_connection_request_id(request_id), params)
.await;
self.thread_start(
to_connection_request_id(request_id),
params,
request_context,
)
.await;
}
ClientRequest::ThreadUnsubscribe { request_id, params } => {
self.thread_unsubscribe(to_connection_request_id(request_id), params)
@@ -1711,7 +1723,7 @@ impl CodexMessageProcessor {
let outgoing = self.outgoing.clone();
let request_for_task = request.clone();
let started_network_proxy_for_task = started_network_proxy;
let use_linux_sandbox_bwrap = self.config.features.enabled(Feature::UseLinuxSandboxBwrap);
let use_legacy_landlock = self.config.features.use_legacy_landlock();
let size = match size.map(crate::command_exec::terminal_size_from_protocol) {
Some(Ok(size)) => Some(size),
Some(Err(error)) => {
@@ -1728,7 +1740,7 @@ impl CodexMessageProcessor {
effective_network_sandbox_policy,
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
use_linux_sandbox_bwrap,
use_legacy_landlock,
) {
Ok(exec_request) => {
if let Err(error) = self
@@ -1806,7 +1818,12 @@ impl CodexMessageProcessor {
}
}
async fn thread_start(&self, request_id: ConnectionRequestId, params: ThreadStartParams) {
async fn thread_start(
&self,
request_id: ConnectionRequestId,
params: ThreadStartParams,
request_context: RequestContext,
) {
let ThreadStartParams {
model,
model_provider,
@@ -1847,8 +1864,8 @@ impl CodexMessageProcessor {
fallback_model_provider: self.config.model_provider_id.clone(),
codex_home: self.config.codex_home.clone(),
};
tokio::spawn(async move {
let request_trace = request_context.request_trace();
let thread_start_task = async move {
Self::thread_start_task(
listener_task_context,
cli_overrides,
@@ -1860,9 +1877,53 @@ impl CodexMessageProcessor {
persist_extended_history,
service_name,
experimental_raw_events,
request_trace,
)
.await;
});
};
self.background_tasks
.spawn(thread_start_task.instrument(request_context.span()));
}
pub(crate) async fn drain_background_tasks(&self) {
self.background_tasks.close();
if tokio::time::timeout(Duration::from_secs(10), self.background_tasks.wait())
.await
.is_err()
{
warn!("timed out waiting for background tasks to shut down; proceeding");
}
}
pub(crate) async fn shutdown_threads(&self) {
let report = self
.thread_manager
.shutdown_all_threads_bounded(Duration::from_secs(10))
.await;
for thread_id in report.submit_failed {
warn!("failed to submit Shutdown to thread {thread_id}");
}
for thread_id in report.timed_out {
warn!("timed out waiting for thread {thread_id} to shut down");
}
}
async fn request_trace_context(
&self,
request_id: &ConnectionRequestId,
) -> Option<codex_protocol::protocol::W3cTraceContext> {
self.outgoing.request_trace_context(request_id).await
}
async fn submit_core_op(
&self,
request_id: &ConnectionRequestId,
thread: &CodexThread,
op: Op,
) -> CodexResult<String> {
thread
.submit_with_trace(op, self.request_trace_context(request_id).await)
.await
}
#[allow(clippy::too_many_arguments)]
@@ -1877,6 +1938,7 @@ impl CodexMessageProcessor {
persist_extended_history: bool,
service_name: Option<String>,
experimental_raw_events: bool,
request_trace: Option<W3cTraceContext>,
) {
let config = match derive_config_from_params(
&cli_overrides,
@@ -1934,6 +1996,7 @@ impl CodexMessageProcessor {
core_dynamic_tools,
persist_extended_history,
service_name,
request_trace,
)
.await
{
@@ -2199,7 +2262,10 @@ impl CodexMessageProcessor {
};
if let Ok(thread) = self.thread_manager.get_thread(thread_id).await {
if let Err(err) = thread.submit(Op::SetThreadName { name }).await {
if let Err(err) = self
.submit_core_op(&request_id, thread.as_ref(), Op::SetThreadName { name })
.await
{
self.send_internal_error(request_id, format!("failed to set thread name: {err}"))
.await;
return;
@@ -2784,7 +2850,14 @@ impl CodexMessageProcessor {
return;
}
if let Err(err) = thread.submit(Op::ThreadRollback { num_turns }).await {
if let Err(err) = self
.submit_core_op(
&request_id,
thread.as_ref(),
Op::ThreadRollback { num_turns },
)
.await
{
// No ThreadRollback event will arrive if an error occurs.
// Clean up and reply immediately.
let thread_state = self.thread_state_manager.thread_state(thread_id).await;
@@ -2812,7 +2885,10 @@ impl CodexMessageProcessor {
}
};
match thread.submit(Op::Compact).await {
match self
.submit_core_op(&request_id, thread.as_ref(), Op::Compact)
.await
{
Ok(_) => {
self.outgoing
.send_response(request_id, ThreadCompactStartResponse {})
@@ -2840,7 +2916,10 @@ impl CodexMessageProcessor {
}
};
match thread.submit(Op::CleanBackgroundTerminals).await {
match self
.submit_core_op(&request_id, thread.as_ref(), Op::CleanBackgroundTerminals)
.await
{
Ok(_) => {
self.outgoing
.send_response(request_id, ThreadBackgroundTerminalsCleanResponse {})
@@ -3298,6 +3377,7 @@ impl CodexMessageProcessor {
thread_history,
self.auth_manager.clone(),
persist_extended_history,
self.request_trace_context(&request_id).await,
)
.await
{
@@ -3823,6 +3903,7 @@ impl CodexMessageProcessor {
config,
rollout_path.clone(),
persist_extended_history,
self.request_trace_context(&request_id).await,
)
.await
{
@@ -4694,26 +4775,10 @@ impl CodexMessageProcessor {
}
async fn wait_for_thread_shutdown(thread: &Arc<CodexThread>) -> ThreadShutdownResult {
match thread.submit(Op::Shutdown).await {
Ok(_) => {
let wait_for_shutdown = async {
loop {
if matches!(thread.agent_status().await, AgentStatus::Shutdown) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
};
if tokio::time::timeout(Duration::from_secs(10), wait_for_shutdown)
.await
.is_err()
{
ThreadShutdownResult::TimedOut
} else {
ThreadShutdownResult::Complete
}
}
Err(_) => ThreadShutdownResult::SubmitFailed,
match tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait()).await {
Ok(Ok(())) => ThreadShutdownResult::Complete,
Ok(Err(_)) => ThreadShutdownResult::SubmitFailed,
Err(_) => ThreadShutdownResult::TimedOut,
}
}
@@ -5400,8 +5465,8 @@ impl CodexMessageProcessor {
PluginSource::Local { path }
}
},
install_policy: plugin.install_policy.map(Into::into),
auth_policy: plugin.auth_policy.map(Into::into),
install_policy: plugin.install_policy.into(),
auth_policy: plugin.auth_policy.into(),
interface: plugin.interface.map(|interface| PluginInterface {
display_name: interface.display_name,
short_description: interface.short_description,
@@ -5654,7 +5719,7 @@ impl CodexMessageProcessor {
.send_response(
request_id,
PluginInstallResponse {
auth_policy: result.auth_policy.map(Into::into),
auth_policy: result.auth_policy.into(),
apps_needing_auth,
},
)
@@ -5799,28 +5864,36 @@ impl CodexMessageProcessor {
// If any overrides are provided, update the session turn context first.
if has_any_overrides {
let _ = thread
.submit(Op::OverrideTurnContext {
cwd: params.cwd,
approval_policy: params.approval_policy.map(AskForApproval::to_core),
sandbox_policy: params.sandbox_policy.map(|p| p.to_core()),
windows_sandbox_level: None,
model: params.model,
effort: params.effort.map(Some),
summary: params.summary,
service_tier: params.service_tier,
collaboration_mode,
personality: params.personality,
})
let _ = self
.submit_core_op(
&request_id,
thread.as_ref(),
Op::OverrideTurnContext {
cwd: params.cwd,
approval_policy: params.approval_policy.map(AskForApproval::to_core),
sandbox_policy: params.sandbox_policy.map(|p| p.to_core()),
windows_sandbox_level: None,
model: params.model,
effort: params.effort.map(Some),
summary: params.summary,
service_tier: params.service_tier,
collaboration_mode,
personality: params.personality,
},
)
.await;
}
// Start the turn by submitting the user input. Return its submission id as turn_id.
let turn_id = thread
.submit(Op::UserInput {
items: mapped_items,
final_output_json_schema: params.output_schema,
})
let turn_id = self
.submit_core_op(
&request_id,
thread.as_ref(),
Op::UserInput {
items: mapped_items,
final_output_json_schema: params.output_schema,
},
)
.await;
match turn_id {
@@ -5977,11 +6050,15 @@ impl CodexMessageProcessor {
return;
};
let submit = thread
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: params.prompt,
session_id: params.session_id,
}))
let submit = self
.submit_core_op(
&request_id,
thread.as_ref(),
Op::RealtimeConversationStart(ConversationStartParams {
prompt: params.prompt,
session_id: params.session_id,
}),
)
.await;
match submit {
@@ -6012,10 +6089,14 @@ impl CodexMessageProcessor {
return;
};
let submit = thread
.submit(Op::RealtimeConversationAudio(ConversationAudioParams {
frame: params.audio.into(),
}))
let submit = self
.submit_core_op(
&request_id,
thread.as_ref(),
Op::RealtimeConversationAudio(ConversationAudioParams {
frame: params.audio.into(),
}),
)
.await;
match submit {
@@ -6046,10 +6127,12 @@ impl CodexMessageProcessor {
return;
};
let submit = thread
.submit(Op::RealtimeConversationText(ConversationTextParams {
text: params.text,
}))
let submit = self
.submit_core_op(
&request_id,
thread.as_ref(),
Op::RealtimeConversationText(ConversationTextParams { text: params.text }),
)
.await;
match submit {
@@ -6080,7 +6163,9 @@ impl CodexMessageProcessor {
return;
};
let submit = thread.submit(Op::RealtimeConversationClose).await;
let submit = self
.submit_core_op(&request_id, thread.as_ref(), Op::RealtimeConversationClose)
.await;
match submit {
Ok(_) => {
@@ -6144,7 +6229,13 @@ impl CodexMessageProcessor {
display_text: &str,
parent_thread_id: String,
) -> std::result::Result<(), JSONRPCErrorError> {
let turn_id = parent_thread.submit(Op::Review { review_request }).await;
let turn_id = self
.submit_core_op(
request_id,
parent_thread.as_ref(),
Op::Review { review_request },
)
.await;
match turn_id {
Ok(turn_id) => {
@@ -6198,7 +6289,13 @@ impl CodexMessageProcessor {
..
} = self
.thread_manager
.fork_thread(usize::MAX, config, rollout_path, false)
.fork_thread(
usize::MAX,
config,
rollout_path,
false,
self.request_trace_context(request_id).await,
)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
@@ -6253,8 +6350,12 @@ impl CodexMessageProcessor {
);
}
let turn_id = review_thread
.submit(Op::Review { review_request })
let turn_id = self
.submit_core_op(
request_id,
review_thread.as_ref(),
Op::Review { review_request },
)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
@@ -6352,7 +6453,9 @@ impl CodexMessageProcessor {
}
// Submit the interrupt; we'll respond upon TurnAborted.
let _ = thread.submit(Op::Interrupt).await;
let _ = self
.submit_core_op(&request_id, thread.as_ref(), Op::Interrupt)
.await;
}
async fn ensure_conversation_listener(
@@ -6507,9 +6610,17 @@ impl CodexMessageProcessor {
};
// For now, we send a notification for every event,
// JSON-serializing the `Event` as-is, but these should
// be migrated to be variants of `ServerNotification`
// instead.
// Legacy `codex/event/*` notifications are still
// produced here because the in-process app-server lane
// (`codex exec` and other in-process consumers) still
// depends on them. External transports now drop
// `OutgoingMessage::Notification` in `transport.rs`,
// so stdio/websocket clients only observe the typed
// `ServerNotification` translations emitted below.
//
// TODO: remove this raw legacy-notification emission
// entirely once the remaining in-process consumers are
// migrated off `codex/event/*`.
let event_formatted = match &event.msg {
EventMsg::TurnStarted(_) => "task_started",
EventMsg::TurnComplete(_) => "task_complete",
+3
View File
@@ -384,6 +384,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
Arc::clone(&outbound_initialized),
Arc::clone(&outbound_experimental_api_enabled),
Arc::clone(&outbound_opted_out_notification_methods),
true,
None,
),
);
@@ -474,6 +475,8 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
}
}
processor.drain_background_tasks().await;
processor.shutdown_threads().await;
processor.connection_closed(IN_PROCESS_CONNECTION_ID).await;
});
let mut pending_request_responses =
+10
View File
@@ -103,6 +103,8 @@ enum OutboundControlEvent {
Opened {
connection_id: ConnectionId,
writer: mpsc::Sender<crate::outgoing_message::OutgoingMessage>,
// Allow codex/event/* notifications to be emitted.
allow_legacy_notifications: bool,
disconnect_sender: Option<CancellationToken>,
initialized: Arc<AtomicBool>,
experimental_api_enabled: Arc<AtomicBool>,
@@ -541,6 +543,7 @@ pub async fn run_main_with_transport(
OutboundControlEvent::Opened {
connection_id,
writer,
allow_legacy_notifications,
disconnect_sender,
initialized,
experimental_api_enabled,
@@ -553,6 +556,7 @@ pub async fn run_main_with_transport(
initialized,
experimental_api_enabled,
opted_out_notification_methods,
allow_legacy_notifications,
disconnect_sender,
),
);
@@ -650,6 +654,7 @@ pub async fn run_main_with_transport(
TransportEvent::ConnectionOpened {
connection_id,
writer,
allow_legacy_notifications,
disconnect_sender,
} => {
let outbound_initialized = Arc::new(AtomicBool::new(false));
@@ -661,6 +666,7 @@ pub async fn run_main_with_transport(
.send(OutboundControlEvent::Opened {
connection_id,
writer,
allow_legacy_notifications,
disconnect_sender,
initialized: Arc::clone(&outbound_initialized),
experimental_api_enabled: Arc::clone(
@@ -803,6 +809,10 @@ pub async fn run_main_with_transport(
}
}
if !shutdown_state.forced() {
processor.drain_background_tasks().await;
processor.shutdown_threads().await;
}
info!("processor task exited (channel closed)");
}
});
+130 -75
View File
@@ -1,4 +1,5 @@
use std::collections::HashSet;
use std::future::Future;
use std::sync::Arc;
use std::sync::RwLock;
use std::sync::atomic::AtomicBool;
@@ -12,6 +13,7 @@ use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::RequestContext;
use crate::transport::AppServerTransport;
use async_trait::async_trait;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
@@ -55,6 +57,7 @@ use codex_core::models_manager::collaboration_mode_presets::CollaborationModesCo
use codex_feedback::CodexFeedback;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use codex_state::log_db::LogDbLayer;
use futures::FutureExt;
use tokio::sync::broadcast;
@@ -240,53 +243,66 @@ impl MessageProcessor {
transport: AppServerTransport,
session: &mut ConnectionSessionState,
) {
let request_method = request.method.as_str();
tracing::trace!(
?connection_id,
request_id = ?request.id,
"app-server request: {request_method}"
);
let request_id = ConnectionRequestId {
connection_id,
request_id: request.id.clone(),
};
let request_span =
crate::app_server_tracing::request_span(&request, transport, connection_id, session);
async {
let request_method = request.method.as_str();
tracing::trace!(
?connection_id,
request_id = ?request.id,
"app-server request: {request_method}"
);
let request_id = ConnectionRequestId {
connection_id,
request_id: request.id.clone(),
};
let request_json = match serde_json::to_value(&request) {
Ok(request_json) => request_json,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("Invalid request: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let request_trace = request.trace.as_ref().map(|trace| W3cTraceContext {
traceparent: trace.traceparent.clone(),
tracestate: trace.tracestate.clone(),
});
let request_context = RequestContext::new(request_id.clone(), request_span, request_trace);
Self::run_request_with_context(
Arc::clone(&self.outgoing),
request_context.clone(),
async {
let request_json = match serde_json::to_value(&request) {
Ok(request_json) => request_json,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("Invalid request: {err}"),
data: None,
};
self.outgoing.send_error(request_id.clone(), error).await;
return;
}
};
let codex_request = match serde_json::from_value::<ClientRequest>(request_json) {
Ok(codex_request) => codex_request,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("Invalid request: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
// Websocket callers finalize outbound readiness in lib.rs after mirroring
// session state into outbound state and sending initialize notifications to
// this specific connection. Passing `None` avoids marking the connection
// ready too early from inside the shared request handler.
self.handle_client_request(connection_id, request_id, codex_request, session, None)
let codex_request = match serde_json::from_value::<ClientRequest>(request_json) {
Ok(codex_request) => codex_request,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("Invalid request: {err}"),
data: None,
};
self.outgoing.send_error(request_id.clone(), error).await;
return;
}
};
// Websocket callers finalize outbound readiness in lib.rs after mirroring
// session state into outbound state and sending initialize notifications to
// this specific connection. Passing `None` avoids marking the connection
// ready too early from inside the shared request handler.
self.handle_client_request(
request_id.clone(),
codex_request,
session,
None,
request_context.clone(),
)
.await;
}
.instrument(request_span)
},
)
.await;
}
@@ -301,31 +317,35 @@ impl MessageProcessor {
session: &mut ConnectionSessionState,
outbound_initialized: &AtomicBool,
) {
let request_id = ConnectionRequestId {
connection_id,
request_id: request.id().clone(),
};
let request_span =
crate::app_server_tracing::typed_request_span(&request, connection_id, session);
async {
let request_id = ConnectionRequestId {
connection_id,
request_id: request.id().clone(),
};
tracing::trace!(
?connection_id,
request_id = ?request_id.request_id,
"app-server typed request"
);
// In-process clients do not have the websocket transport loop that performs
// post-initialize bookkeeping, so they still finalize outbound readiness in
// the shared request handler.
self.handle_client_request(
connection_id,
request_id,
request,
session,
Some(outbound_initialized),
)
.await;
}
.instrument(request_span)
let request_context = RequestContext::new(request_id.clone(), request_span, None);
tracing::trace!(
?connection_id,
request_id = ?request_id.request_id,
"app-server typed request"
);
Self::run_request_with_context(
Arc::clone(&self.outgoing),
request_context.clone(),
async {
// In-process clients do not have the websocket transport loop that performs
// post-initialize bookkeeping, so they still finalize outbound readiness in
// the shared request handler.
self.handle_client_request(
request_id.clone(),
request,
session,
Some(outbound_initialized),
request_context.clone(),
)
.await;
},
)
.await;
}
@@ -342,6 +362,19 @@ impl MessageProcessor {
tracing::info!("<- typed notification: {:?}", notification);
}
async fn run_request_with_context<F>(
outgoing: Arc<OutgoingMessageSender>,
request_context: RequestContext,
request_fut: F,
) where
F: Future<Output = ()>,
{
outgoing
.register_request_context(request_context.clone())
.await;
request_fut.instrument(request_context.span()).await;
}
pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver<ThreadId> {
self.codex_message_processor.thread_created_receiver()
}
@@ -384,7 +417,16 @@ impl MessageProcessor {
.await;
}
pub(crate) async fn drain_background_tasks(&self) {
self.codex_message_processor.drain_background_tasks().await;
}
pub(crate) async fn shutdown_threads(&self) {
self.codex_message_processor.shutdown_threads().await;
}
pub(crate) async fn connection_closed(&mut self, connection_id: ConnectionId) {
self.outgoing.connection_closed(connection_id).await;
self.codex_message_processor
.connection_closed(connection_id)
.await;
@@ -410,20 +452,21 @@ impl MessageProcessor {
async fn handle_client_request(
&mut self,
connection_id: ConnectionId,
request_id: ConnectionRequestId,
connection_request_id: ConnectionRequestId,
codex_request: ClientRequest,
session: &mut ConnectionSessionState,
// `Some(...)` means the caller wants initialize to immediately mark the
// connection outbound-ready. Websocket JSON-RPC calls pass `None` so
// lib.rs can deliver connection-scoped initialize notifications first.
outbound_initialized: Option<&AtomicBool>,
request_context: RequestContext,
) {
let connection_id = connection_request_id.connection_id;
match codex_request {
// Handle Initialize internally so CodexMessageProcessor does not have to concern
// itself with the `initialized` bool.
ClientRequest::Initialize { request_id, params } => {
let request_id = ConnectionRequestId {
let connection_request_id = ConnectionRequestId {
connection_id,
request_id,
};
@@ -433,7 +476,7 @@ impl MessageProcessor {
message: "Already initialized".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
self.outgoing.send_error(connection_request_id, error).await;
return;
}
@@ -473,7 +516,9 @@ impl MessageProcessor {
),
data: None,
};
self.outgoing.send_error(request_id.clone(), error).await;
self.outgoing
.send_error(connection_request_id.clone(), error)
.await;
return;
}
SetOriginatorError::AlreadyInitialized => {
@@ -492,7 +537,9 @@ impl MessageProcessor {
let user_agent = get_codex_user_agent();
let response = InitializeResponse { user_agent };
self.outgoing.send_response(request_id, response).await;
self.outgoing
.send_response(connection_request_id, response)
.await;
session.initialized = true;
if let Some(outbound_initialized) = outbound_initialized {
@@ -513,7 +560,7 @@ impl MessageProcessor {
message: "Not initialized".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
self.outgoing.send_error(connection_request_id, error).await;
return;
}
}
@@ -526,7 +573,7 @@ impl MessageProcessor {
message: experimental_required_message(reason),
data: None,
};
self.outgoing.send_error(request_id, error).await;
self.outgoing.send_error(connection_request_id, error).await;
return;
}
@@ -596,7 +643,12 @@ impl MessageProcessor {
// inline the full `CodexMessageProcessor::process_request` future, which
// can otherwise push worker-thread stack usage over the edge.
self.codex_message_processor
.process_request(connection_id, other, session.app_server_client_name.clone())
.process_request(
connection_id,
other,
session.app_server_client_name.clone(),
request_context,
)
.boxed()
.await;
}
@@ -673,3 +725,6 @@ impl MessageProcessor {
}
}
}
#[cfg(test)]
mod tracing_tests;
@@ -0,0 +1,544 @@
use super::ConnectionSessionState;
use super::MessageProcessor;
use super::MessageProcessorArgs;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::transport::AppServerTransport;
use anyhow::Result;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::write_mock_responses_config_toml;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use codex_arg0::Arg0DispatchPaths;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use opentelemetry::global;
use opentelemetry::trace::SpanId;
use opentelemetry::trace::SpanKind;
use opentelemetry::trace::TraceId;
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::trace::InMemorySpanExporter;
use opentelemetry_sdk::trace::SdkTracerProvider;
use opentelemetry_sdk::trace::SpanData;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::OnceLock;
use tempfile::TempDir;
use tokio::sync::mpsc;
use tracing_subscriber::layer::SubscriberExt;
use wiremock::MockServer;
const TEST_CONNECTION_ID: ConnectionId = ConnectionId(7);
struct TestTracing {
exporter: InMemorySpanExporter,
provider: SdkTracerProvider,
}
struct RemoteTrace {
trace_id: TraceId,
parent_span_id: SpanId,
context: W3cTraceContext,
}
impl RemoteTrace {
fn new(trace_id: &str, parent_span_id: &str) -> Self {
let trace_id = TraceId::from_hex(trace_id).expect("trace id");
let parent_span_id = SpanId::from_hex(parent_span_id).expect("parent span id");
let context = W3cTraceContext {
traceparent: Some(format!("00-{trace_id}-{parent_span_id}-01")),
tracestate: Some("vendor=value".to_string()),
};
Self {
trace_id,
parent_span_id,
context,
}
}
}
fn init_test_tracing() -> &'static TestTracing {
static TEST_TRACING: OnceLock<TestTracing> = OnceLock::new();
TEST_TRACING.get_or_init(|| {
let exporter = InMemorySpanExporter::default();
let provider = SdkTracerProvider::builder()
.with_simple_exporter(exporter.clone())
.build();
let tracer = provider.tracer("codex-app-server-message-processor-tests");
global::set_text_map_propagator(TraceContextPropagator::new());
let subscriber =
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
tracing::subscriber::set_global_default(subscriber)
.expect("global tracing subscriber should only be installed once");
TestTracing { exporter, provider }
})
}
fn request_from_client_request(request: ClientRequest) -> JSONRPCRequest {
serde_json::from_value(serde_json::to_value(request).expect("serialize client request"))
.expect("client request should convert to JSON-RPC")
}
fn tracing_test_guard() -> &'static tokio::sync::Mutex<()> {
static GUARD: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
GUARD.get_or_init(|| tokio::sync::Mutex::new(()))
}
struct TracingHarness {
_server: MockServer,
_codex_home: TempDir,
processor: MessageProcessor,
outgoing_rx: mpsc::Receiver<crate::outgoing_message::OutgoingEnvelope>,
session: ConnectionSessionState,
tracing: &'static TestTracing,
}
impl TracingHarness {
async fn new() -> Result<Self> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
let config = Arc::new(build_test_config(codex_home.path(), &server.uri()).await?);
let (processor, outgoing_rx) = build_test_processor(config);
let tracing = init_test_tracing();
tracing.exporter.reset();
tracing::callsite::rebuild_interest_cache();
let mut harness = Self {
_server: server,
_codex_home: codex_home,
processor,
outgoing_rx,
session: ConnectionSessionState::default(),
tracing,
};
let _: InitializeResponse = harness
.request(
ClientRequest::Initialize {
request_id: RequestId::Integer(1),
params: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
..Default::default()
}),
},
},
None,
)
.await;
assert!(harness.session.initialized);
Ok(harness)
}
fn reset_tracing(&self) {
self.tracing.exporter.reset();
}
async fn request<T>(&mut self, request: ClientRequest, trace: Option<W3cTraceContext>) -> T
where
T: serde::de::DeserializeOwned,
{
let request_id = match request.id() {
RequestId::Integer(request_id) => *request_id,
request_id => panic!("expected integer request id in test harness, got {request_id:?}"),
};
let mut request = request_from_client_request(request);
request.trace = trace;
self.processor
.process_request(
TEST_CONNECTION_ID,
request,
AppServerTransport::Stdio,
&mut self.session,
)
.await;
read_response(&mut self.outgoing_rx, request_id).await
}
async fn start_thread(
&mut self,
request_id: i64,
trace: Option<W3cTraceContext>,
) -> ThreadStartResponse {
let response = self
.request(
ClientRequest::ThreadStart {
request_id: RequestId::Integer(request_id),
params: ThreadStartParams {
ephemeral: Some(true),
..ThreadStartParams::default()
},
},
trace,
)
.await;
read_thread_started_notification(&mut self.outgoing_rx).await;
response
}
}
async fn build_test_config(codex_home: &Path, server_uri: &str) -> Result<Config> {
write_mock_responses_config_toml(
codex_home,
server_uri,
&BTreeMap::new(),
8_192,
Some(false),
"mock_provider",
"compact",
)?;
Ok(ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await?)
}
fn build_test_processor(
config: Arc<Config>,
) -> (
MessageProcessor,
mpsc::Receiver<crate::outgoing_message::OutgoingEnvelope>,
) {
let (outgoing_tx, outgoing_rx) = mpsc::channel(16);
let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx));
let processor = MessageProcessor::new(MessageProcessorArgs {
outgoing,
arg0_paths: Arg0DispatchPaths::default(),
config,
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
config_warnings: Vec::new(),
session_source: SessionSource::VSCode,
enable_codex_api_key_env: false,
});
(processor, outgoing_rx)
}
fn span_attr<'a>(span: &'a SpanData, key: &str) -> Option<&'a str> {
span.attributes
.iter()
.find(|kv| kv.key.as_str() == key)
.and_then(|kv| match &kv.value {
opentelemetry::Value::String(value) => Some(value.as_str()),
_ => None,
})
}
fn find_rpc_span_with_trace<'a>(
spans: &'a [SpanData],
kind: SpanKind,
method: &str,
trace_id: TraceId,
) -> &'a SpanData {
spans
.iter()
.find(|span| {
span.span_kind == kind
&& span_attr(span, "rpc.system") == Some("jsonrpc")
&& span_attr(span, "rpc.method") == Some(method)
&& span.span_context.trace_id() == trace_id
})
.unwrap_or_else(|| {
panic!(
"missing {kind:?} span for rpc.method={method} trace={trace_id}; exported spans:\n{}",
format_spans(spans)
)
})
}
fn find_span_by_name_with_trace<'a>(
spans: &'a [SpanData],
name: &str,
trace_id: TraceId,
) -> &'a SpanData {
spans
.iter()
.find(|span| span.name.as_ref() == name && span.span_context.trace_id() == trace_id)
.unwrap_or_else(|| {
panic!(
"missing span named {name} for trace={trace_id}; exported spans:\n{}",
format_spans(spans)
)
})
}
fn format_spans(spans: &[SpanData]) -> String {
spans
.iter()
.map(|span| {
let rpc_method = span_attr(span, "rpc.method").unwrap_or("-");
format!(
"name={} span_id={} kind={:?} parent={} trace={} rpc.method={}",
span.name,
span.span_context.span_id(),
span.span_kind,
span.parent_span_id,
span.span_context.trace_id(),
rpc_method
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn assert_span_descends_from(spans: &[SpanData], child: &SpanData, ancestor: &SpanData) {
let ancestor_span_id = ancestor.span_context.span_id();
let mut parent_span_id = child.parent_span_id;
while parent_span_id != SpanId::INVALID {
if parent_span_id == ancestor_span_id {
return;
}
let Some(parent_span) = spans
.iter()
.find(|span| span.span_context.span_id() == parent_span_id)
else {
break;
};
parent_span_id = parent_span.parent_span_id;
}
panic!(
"span {} does not descend from {}; exported spans:\n{}",
child.name,
ancestor.name,
format_spans(spans)
);
}
async fn read_response<T: serde::de::DeserializeOwned>(
outgoing_rx: &mut mpsc::Receiver<crate::outgoing_message::OutgoingEnvelope>,
request_id: i64,
) -> T {
loop {
let envelope = tokio::time::timeout(std::time::Duration::from_secs(5), outgoing_rx.recv())
.await
.expect("timed out waiting for response")
.expect("outgoing channel closed");
let crate::outgoing_message::OutgoingEnvelope::ToConnection {
connection_id,
message,
} = envelope
else {
continue;
};
if connection_id != TEST_CONNECTION_ID {
continue;
}
let crate::outgoing_message::OutgoingMessage::Response(response) = message else {
continue;
};
if response.id != RequestId::Integer(request_id) {
continue;
}
return serde_json::from_value(response.result)
.expect("response payload should deserialize");
}
}
async fn read_thread_started_notification(
outgoing_rx: &mut mpsc::Receiver<crate::outgoing_message::OutgoingEnvelope>,
) {
loop {
let envelope = tokio::time::timeout(std::time::Duration::from_secs(5), outgoing_rx.recv())
.await
.expect("timed out waiting for thread/started notification")
.expect("outgoing channel closed");
match envelope {
crate::outgoing_message::OutgoingEnvelope::ToConnection {
connection_id,
message,
} => {
if connection_id != TEST_CONNECTION_ID {
continue;
}
let crate::outgoing_message::OutgoingMessage::AppServerNotification(notification) =
message
else {
continue;
};
if matches!(
notification,
codex_app_server_protocol::ServerNotification::ThreadStarted(_)
) {
return;
}
}
crate::outgoing_message::OutgoingEnvelope::Broadcast { message } => {
let crate::outgoing_message::OutgoingMessage::AppServerNotification(notification) =
message
else {
continue;
};
if matches!(
notification,
codex_app_server_protocol::ServerNotification::ThreadStarted(_)
) {
return;
}
}
}
}
}
async fn wait_for_exported_spans<F>(tracing: &TestTracing, predicate: F) -> Vec<SpanData>
where
F: Fn(&[SpanData]) -> bool,
{
let mut last_spans = Vec::new();
for _ in 0..200 {
tokio::task::yield_now().await;
tracing
.provider
.force_flush()
.expect("force flush should succeed");
let spans = tracing.exporter.get_finished_spans().expect("span export");
last_spans = spans.clone();
if predicate(&spans) {
return spans;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
panic!(
"timed out waiting for expected exported spans:\n{}",
format_spans(&last_spans)
);
}
#[tokio::test(flavor = "current_thread")]
async fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Result<()> {
let _guard = tracing_test_guard().lock().await;
let mut harness = TracingHarness::new().await?;
let RemoteTrace {
trace_id: remote_trace_id,
context: remote_trace,
..
} = RemoteTrace::new("00000000000000000000000000000011", "0000000000000022");
let _: ThreadStartResponse = harness.start_thread(2, Some(remote_trace)).await;
drop(harness.processor);
let spans = wait_for_exported_spans(harness.tracing, |spans| {
spans.iter().any(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.method") == Some("thread/start")
&& span.span_context.trace_id() == remote_trace_id
}) && spans
.iter()
.any(|span| span.name.as_ref() == "thread_spawn")
})
.await;
let server_request_span =
find_rpc_span_with_trace(&spans, SpanKind::Server, "thread/start", remote_trace_id);
let thread_spawn_span = find_span_by_name_with_trace(&spans, "thread_spawn", remote_trace_id);
let session_init_span = find_span_by_name_with_trace(&spans, "session_init", remote_trace_id);
assert_eq!(server_request_span.name.as_ref(), "thread/start");
assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id);
assert_ne!(server_request_span.span_context.span_id(), SpanId::INVALID);
assert_span_descends_from(&spans, thread_spawn_span, server_request_span);
assert_span_descends_from(&spans, session_init_span, server_request_span);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
let _guard = tracing_test_guard().lock().await;
let mut harness = TracingHarness::new().await?;
let thread_start_response = harness.start_thread(2, None).await;
let thread_id = thread_start_response.thread.id.clone();
harness.reset_tracing();
let RemoteTrace {
trace_id: remote_trace_id,
parent_span_id: remote_parent_span_id,
context: remote_trace,
} = RemoteTrace::new("00000000000000000000000000000077", "0000000000000088");
let _: TurnStartResponse = harness
.request(
ClientRequest::TurnStart {
request_id: RequestId::Integer(3),
params: TurnStartParams {
thread_id,
input: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
service_tier: None,
effort: None,
summary: None,
personality: None,
output_schema: None,
collaboration_mode: None,
},
},
Some(remote_trace),
)
.await;
let spans = wait_for_exported_spans(harness.tracing, |spans| {
spans
.iter()
.any(|span| span.name.as_ref() == "submission_dispatch")
&& spans
.iter()
.any(|span| span.name.as_ref() == "session_task.turn")
&& spans.iter().any(|span| span.name.as_ref() == "run_turn")
})
.await;
drop(harness.processor);
tokio::task::yield_now().await;
let server_request_span =
find_rpc_span_with_trace(&spans, SpanKind::Server, "turn/start", remote_trace_id);
let submission_dispatch_span =
find_span_by_name_with_trace(&spans, "submission_dispatch", remote_trace_id);
let session_task_turn_span =
find_span_by_name_with_trace(&spans, "session_task.turn", remote_trace_id);
let run_turn_span = find_span_by_name_with_trace(&spans, "run_turn", remote_trace_id);
assert_eq!(server_request_span.parent_span_id, remote_parent_span_id);
assert!(server_request_span.parent_span_is_remote);
assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id);
assert_span_descends_from(&spans, submission_dispatch_span, server_request_span);
assert_span_descends_from(&spans, session_task_turn_span, server_request_span);
assert_span_descends_from(&spans, run_turn_span, server_request_span);
assert_span_descends_from(&spans, session_task_turn_span, submission_dispatch_span);
assert_span_descends_from(&spans, run_turn_span, session_task_turn_span);
Ok(())
}
+187 -21
View File
@@ -9,11 +9,15 @@ use codex_app_server_protocol::Result;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerRequestPayload;
use codex_otel::span_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::protocol::W3cTraceContext;
use serde::Serialize;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tracing::Instrument;
use tracing::Span;
use tracing::warn;
use crate::error_code::INTERNAL_ERROR_CODE;
@@ -35,6 +39,37 @@ pub(crate) struct ConnectionRequestId {
pub(crate) request_id: RequestId,
}
/// Trace data we keep for an incoming request until we send its final
/// response or error.
#[derive(Clone)]
pub(crate) struct RequestContext {
request_id: ConnectionRequestId,
span: Span,
parent_trace: Option<W3cTraceContext>,
}
impl RequestContext {
pub(crate) fn new(
request_id: ConnectionRequestId,
span: Span,
parent_trace: Option<W3cTraceContext>,
) -> Self {
Self {
request_id,
span,
parent_trace,
}
}
pub(crate) fn request_trace(&self) -> Option<W3cTraceContext> {
span_w3c_trace_context(&self.span).or_else(|| self.parent_trace.clone())
}
pub(crate) fn span(&self) -> Span {
self.span.clone()
}
}
#[derive(Debug, Clone)]
pub(crate) enum OutgoingEnvelope {
ToConnection {
@@ -51,6 +86,10 @@ pub(crate) struct OutgoingMessageSender {
next_server_request_id: AtomicI64,
sender: mpsc::Sender<OutgoingEnvelope>,
request_id_to_callback: Mutex<HashMap<RequestId, PendingCallbackEntry>>,
/// Incoming requests that are still waiting on a final response or error.
/// We keep them here because this is where responses, errors, and
/// disconnect cleanup all get handled.
request_contexts: Mutex<HashMap<ConnectionRequestId, RequestContext>>,
}
#[derive(Clone)]
@@ -142,9 +181,48 @@ impl OutgoingMessageSender {
next_server_request_id: AtomicI64::new(0),
sender,
request_id_to_callback: Mutex::new(HashMap::new()),
request_contexts: Mutex::new(HashMap::new()),
}
}
pub(crate) async fn register_request_context(&self, request_context: RequestContext) {
let mut request_contexts = self.request_contexts.lock().await;
if request_contexts
.insert(request_context.request_id.clone(), request_context)
.is_some()
{
warn!("replaced unresolved request context");
}
}
pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
let mut request_contexts = self.request_contexts.lock().await;
request_contexts.retain(|request_id, _| request_id.connection_id != connection_id);
}
pub(crate) async fn request_trace_context(
&self,
request_id: &ConnectionRequestId,
) -> Option<W3cTraceContext> {
let request_contexts = self.request_contexts.lock().await;
request_contexts
.get(request_id)
.and_then(RequestContext::request_trace)
}
async fn take_request_context(
&self,
request_id: &ConnectionRequestId,
) -> Option<RequestContext> {
let mut request_contexts = self.request_contexts.lock().await;
request_contexts.remove(request_id)
}
#[cfg(test)]
async fn request_context_count(&self) -> usize {
self.request_contexts.lock().await.len()
}
pub(crate) async fn send_request(
&self,
request: ServerRequestPayload,
@@ -353,25 +431,24 @@ impl OutgoingMessageSender {
request_id: ConnectionRequestId,
response: T,
) {
let request_context = self.take_request_context(&request_id).await;
match serde_json::to_value(response) {
Ok(result) => {
let outgoing_message = OutgoingMessage::Response(OutgoingResponse {
id: request_id.request_id,
id: request_id.request_id.clone(),
result,
});
if let Err(err) = self
.sender
.send(OutgoingEnvelope::ToConnection {
connection_id: request_id.connection_id,
message: outgoing_message,
})
.await
{
warn!("failed to send response to client: {err:?}");
}
self.send_outgoing_message_to_connection(
request_context,
request_id.connection_id,
outgoing_message,
"response",
)
.await;
}
Err(err) => {
self.send_error(
self.send_error_inner(
request_context,
request_id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
@@ -461,20 +538,50 @@ impl OutgoingMessageSender {
&self,
request_id: ConnectionRequestId,
error: JSONRPCErrorError,
) {
let request_context = self.take_request_context(&request_id).await;
self.send_error_inner(request_context, request_id, error)
.await;
}
async fn send_error_inner(
&self,
request_context: Option<RequestContext>,
request_id: ConnectionRequestId,
error: JSONRPCErrorError,
) {
let outgoing_message = OutgoingMessage::Error(OutgoingError {
id: request_id.request_id,
error,
});
if let Err(err) = self
.sender
.send(OutgoingEnvelope::ToConnection {
connection_id: request_id.connection_id,
message: outgoing_message,
})
.await
{
warn!("failed to send error to client: {err:?}");
self.send_outgoing_message_to_connection(
request_context,
request_id.connection_id,
outgoing_message,
"error",
)
.await;
}
async fn send_outgoing_message_to_connection(
&self,
request_context: Option<RequestContext>,
connection_id: ConnectionId,
message: OutgoingMessage,
message_kind: &'static str,
) {
let send_fut = self.sender.send(OutgoingEnvelope::ToConnection {
connection_id,
message,
});
let send_result = if let Some(request_context) = request_context {
send_fut.instrument(request_context.span()).await
} else {
send_fut.await
};
if let Err(err) = send_result {
warn!("failed to send {message_kind} to client: {err:?}");
}
}
}
@@ -738,6 +845,31 @@ mod tests {
}
}
#[tokio::test]
async fn send_response_clears_registered_request_context() {
let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(4);
let outgoing = OutgoingMessageSender::new(tx);
let request_id = ConnectionRequestId {
connection_id: ConnectionId(42),
request_id: RequestId::Integer(7),
};
outgoing
.register_request_context(RequestContext::new(
request_id.clone(),
tracing::info_span!("app_server.request", rpc.method = "thread/start"),
None,
))
.await;
assert_eq!(outgoing.request_context_count().await, 1);
outgoing
.send_response(request_id, json!({ "ok": true }))
.await;
assert_eq!(outgoing.request_context_count().await, 0);
}
#[tokio::test]
async fn send_error_routes_to_target_connection() {
let (tx, mut rx) = mpsc::channel::<OutgoingEnvelope>(4);
@@ -775,6 +907,40 @@ mod tests {
}
}
#[tokio::test]
async fn connection_closed_clears_registered_request_contexts() {
let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(4);
let outgoing = OutgoingMessageSender::new(tx);
let closed_connection_request = ConnectionRequestId {
connection_id: ConnectionId(9),
request_id: RequestId::Integer(3),
};
let open_connection_request = ConnectionRequestId {
connection_id: ConnectionId(10),
request_id: RequestId::Integer(4),
};
outgoing
.register_request_context(RequestContext::new(
closed_connection_request,
tracing::info_span!("app_server.request", rpc.method = "turn/interrupt"),
None,
))
.await;
outgoing
.register_request_context(RequestContext::new(
open_connection_request,
tracing::info_span!("app_server.request", rpc.method = "turn/start"),
None,
))
.await;
assert_eq!(outgoing.request_context_count().await, 2);
outgoing.connection_closed(ConnectionId(9)).await;
assert_eq!(outgoing.request_context_count().await, 1);
}
#[tokio::test]
async fn notify_client_error_forwards_error_to_waiter() {
let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(4);
+116 -24
View File
@@ -166,6 +166,7 @@ pub(crate) enum TransportEvent {
ConnectionOpened {
connection_id: ConnectionId,
writer: mpsc::Sender<OutgoingMessage>,
allow_legacy_notifications: bool,
disconnect_sender: Option<CancellationToken>,
},
ConnectionClosed {
@@ -203,6 +204,7 @@ pub(crate) struct OutboundConnectionState {
pub(crate) initialized: Arc<AtomicBool>,
pub(crate) experimental_api_enabled: Arc<AtomicBool>,
pub(crate) opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
pub(crate) allow_legacy_notifications: bool,
pub(crate) writer: mpsc::Sender<OutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
}
@@ -213,12 +215,14 @@ impl OutboundConnectionState {
initialized: Arc<AtomicBool>,
experimental_api_enabled: Arc<AtomicBool>,
opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
allow_legacy_notifications: bool,
disconnect_sender: Option<CancellationToken>,
) -> Self {
Self {
initialized,
experimental_api_enabled,
opted_out_notification_methods,
allow_legacy_notifications,
writer,
disconnect_sender,
}
@@ -246,6 +250,7 @@ pub(crate) async fn start_stdio_connection(
.send(TransportEvent::ConnectionOpened {
connection_id,
writer: writer_tx,
allow_legacy_notifications: false,
disconnect_sender: None,
})
.await
@@ -348,6 +353,7 @@ async fn run_websocket_connection(
.send(TransportEvent::ConnectionOpened {
connection_id,
writer: writer_tx,
allow_legacy_notifications: false,
disconnect_sender: Some(disconnect_token.clone()),
})
.await
@@ -555,6 +561,16 @@ fn should_skip_notification_for_connection(
connection_state: &OutboundConnectionState,
message: &OutgoingMessage,
) -> bool {
if !connection_state.allow_legacy_notifications
&& matches!(message, OutgoingMessage::Notification(_))
{
// Raw legacy `codex/event/*` notifications are still emitted upstream
// for in-process compatibility, but they are no longer part of the
// external app-server contract. Keep dropping them here until the
// producer path can be deleted entirely.
return true;
}
let Ok(opted_out_notification_methods) = connection_state.opted_out_notification_methods.read()
else {
warn!("failed to read outbound opted-out notifications");
@@ -931,6 +947,7 @@ mod tests {
initialized,
Arc::new(AtomicBool::new(true)),
opted_out_notification_methods,
false,
None,
),
);
@@ -955,6 +972,89 @@ mod tests {
);
}
#[tokio::test]
async fn to_connection_legacy_notifications_are_dropped_for_external_clients() {
let connection_id = ConnectionId(10);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
false,
None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::Notification(
crate::outgoing_message::OutgoingNotification {
method: "codex/event/task_started".to_string(),
params: None,
},
),
},
)
.await;
assert!(
writer_rx.try_recv().is_err(),
"legacy notifications should not reach external clients"
);
}
#[tokio::test]
async fn to_connection_legacy_notifications_are_preserved_for_in_process_clients() {
let connection_id = ConnectionId(11);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
true,
None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::Notification(
crate::outgoing_message::OutgoingNotification {
method: "codex/event/task_started".to_string(),
params: None,
},
),
},
)
.await;
let message = writer_rx
.recv()
.await
.expect("legacy notification should reach in-process clients");
assert!(matches!(
message,
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method,
params: None,
}) if method == "codex/event/task_started"
));
}
#[tokio::test]
async fn command_execution_request_approval_strips_experimental_fields_without_capability() {
let connection_id = ConnectionId(8);
@@ -968,6 +1068,7 @@ mod tests {
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(false)),
Arc::new(RwLock::new(HashSet::new())),
false,
None,
),
);
@@ -1034,6 +1135,7 @@ mod tests {
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
false,
None,
),
);
@@ -1121,6 +1223,7 @@ mod tests {
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
false,
Some(fast_disconnect_token.clone()),
),
);
@@ -1131,6 +1234,7 @@ mod tests {
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
false,
Some(slow_disconnect_token.clone()),
),
);
@@ -1159,20 +1263,14 @@ mod tests {
),
)
.await
.expect("broadcast should not block on a full writer");
assert!(!connections.contains_key(&slow_connection_id));
assert!(slow_disconnect_token.is_cancelled());
.expect("broadcast should return even when legacy notifications are dropped");
assert!(connections.contains_key(&slow_connection_id));
assert!(!slow_disconnect_token.is_cancelled());
assert!(!fast_disconnect_token.is_cancelled());
let fast_message = fast_writer_rx
.try_recv()
.expect("fast connection should receive broadcast");
assert!(matches!(
fast_message,
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method,
params: None,
}) if method == "codex/event/test"
));
assert!(
fast_writer_rx.try_recv().is_err(),
"broadcast legacy notification should be dropped for fast connections"
);
let slow_message = slow_writer_rx
.try_recv()
@@ -1208,6 +1306,7 @@ mod tests {
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
false,
None,
),
);
@@ -1232,14 +1331,9 @@ mod tests {
.await
.expect("first queued message should be readable")
.expect("first queued message should exist");
let second = timeout(Duration::from_millis(100), writer_rx.recv())
.await
.expect("second message should eventually be delivered")
.expect("second message should exist");
timeout(Duration::from_millis(100), route_task)
.await
.expect("routing should finish after writer drains")
.expect("routing should finish immediately when legacy notifications are dropped")
.expect("routing task should succeed");
assert!(matches!(
@@ -1250,11 +1344,9 @@ mod tests {
}) if method == "queued"
));
assert!(matches!(
second,
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method,
params: None,
}) if method == "second"
writer_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
| Err(tokio::sync::mpsc::error::TryRecvError::Disconnected)
));
}
}
@@ -594,7 +594,7 @@ impl McpProcess {
/// Deterministically clean up an intentionally in-flight turn.
///
/// Some tests assert behavior while a turn is still running. Returning from those tests
/// without an explicit interrupt + `codex/event/turn_aborted` wait can leave in-flight work
/// without an explicit interrupt + terminal turn notification wait can leave in-flight work
/// racing teardown and intermittently show up as `LEAK` in nextest.
///
/// In rare races, the turn can also fail or complete on its own after we send
@@ -631,18 +631,19 @@ impl McpProcess {
}
match tokio::time::timeout(
read_timeout,
self.read_stream_until_notification_message("codex/event/turn_aborted"),
self.read_stream_until_notification_message("turn/completed"),
)
.await
{
Ok(result) => {
result.with_context(|| "failed while waiting for turn aborted notification")?;
result.with_context(|| "failed while waiting for terminal turn notification")?;
}
Err(err) => {
if self.pending_turn_completed_notification(&thread_id, &turn_id) {
return Ok(());
}
return Err(err).with_context(|| "timed out waiting for turn aborted notification");
return Err(err)
.with_context(|| "timed out waiting for terminal turn notification");
}
}
Ok(())
@@ -710,6 +710,12 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let marker = format!(
"codex-command-exec-marker-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos()
);
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
@@ -726,7 +732,12 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
"command/exec",
101,
Some(serde_json::json!({
"command": ["sh", "-lc", "printf 'ready\\n%s\\n' $$; sleep 30"],
"command": [
"python3",
"-c",
"import time; print('ready', flush=True); time.sleep(30)",
marker,
],
"processId": "shared-process",
"streamStdoutStderr": true,
})),
@@ -737,12 +748,8 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
assert_eq!(delta.process_id, "shared-process");
assert_eq!(delta.stream, CommandExecOutputStream::Stdout);
let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?;
let pid = delta_text
.lines()
.last()
.context("delta should include shell pid")?
.parse::<u32>()
.context("parse shell pid")?;
assert!(delta_text.contains("ready"));
wait_for_process_marker(&marker, true).await?;
send_request(
&mut ws2,
@@ -766,12 +773,12 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
terminate_error.error.message,
"no active command/exec for process id \"shared-process\""
);
assert!(process_is_alive(pid)?);
wait_for_process_marker(&marker, true).await?;
assert_no_message(&mut ws2, Duration::from_millis(250)).await?;
ws1.close(None).await?;
wait_for_process_exit(pid).await?;
wait_for_process_marker(&marker, false).await?;
process
.kill()
@@ -855,24 +862,25 @@ async fn read_initialize_response(
}
}
async fn wait_for_process_exit(pid: u32) -> Result<()> {
async fn wait_for_process_marker(marker: &str, should_exist: bool) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if !process_is_alive(pid)? {
if process_with_marker_exists(marker)? == should_exist {
return Ok(());
}
if Instant::now() >= deadline {
anyhow::bail!("process {pid} was still alive after websocket disconnect");
let expectation = if should_exist { "appear" } else { "exit" };
anyhow::bail!("process marker {marker:?} did not {expectation} before timeout");
}
sleep(Duration::from_millis(50)).await;
}
}
fn process_is_alive(pid: u32) -> Result<bool> {
let status = std::process::Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.status()
.context("spawn kill -0")?;
Ok(status.success())
fn process_with_marker_exists(marker: &str) -> Result<bool> {
let output = std::process::Command::new("ps")
.args(["-axo", "command"])
.output()
.context("spawn ps -axo command")?;
let stdout = String::from_utf8(output.stdout).context("decode ps output")?;
Ok(stdout.lines().any(|line| line.contains(marker)))
}
@@ -139,10 +139,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu
},
Some(InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: Some(vec![
"thread/started".to_string(),
"codex/event/session_configured".to_string(),
]),
opt_out_notification_methods: Some(vec!["thread/started".to_string()]),
}),
),
)
@@ -66,7 +66,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
const CONNECTOR_ID: &str = "calendar";
const CONNECTOR_NAME: &str = "Calendar";
const TOOL_NAME: &str = "calendar_confirm_action";
const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar_confirm_action";
const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar-confirm-action";
const TOOL_CALL_ID: &str = "call-calendar-confirm";
const ELICITATION_MESSAGE: &str = "Allow this request?";
@@ -191,7 +191,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
"sample-plugin",
"./sample-plugin",
None,
Some("ON_INSTALL"),
None,
)?;
write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?;
let marketplace_path =
@@ -217,7 +217,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
assert_eq!(
response,
PluginInstallResponse {
auth_policy: Some(PluginAuthPolicy::OnInstall),
auth_policy: PluginAuthPolicy::OnInstall,
apps_needing_auth: vec![AppSummary {
id: "alpha".to_string(),
name: "Alpha".to_string(),
@@ -299,7 +299,7 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
assert_eq!(
response,
PluginInstallResponse {
auth_policy: Some(PluginAuthPolicy::OnUse),
auth_policy: PluginAuthPolicy::OnUse,
apps_needing_auth: vec![AppSummary {
id: "alpha".to_string(),
name: "Alpha".to_string(),
@@ -26,6 +26,7 @@ use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
#[tokio::test]
async fn plugin_list_returns_invalid_request_for_invalid_marketplace_file() -> Result<()> {
@@ -224,10 +225,26 @@ enabled = false
assert_eq!(marketplace.plugins[0].name, "enabled-plugin");
assert_eq!(marketplace.plugins[0].installed, true);
assert_eq!(marketplace.plugins[0].enabled, true);
assert_eq!(
marketplace.plugins[0].install_policy,
PluginInstallPolicy::Available
);
assert_eq!(
marketplace.plugins[0].auth_policy,
PluginAuthPolicy::OnInstall
);
assert_eq!(marketplace.plugins[1].id, "disabled-plugin@codex-curated");
assert_eq!(marketplace.plugins[1].name, "disabled-plugin");
assert_eq!(marketplace.plugins[1].installed, true);
assert_eq!(marketplace.plugins[1].enabled, false);
assert_eq!(
marketplace.plugins[1].install_policy,
PluginInstallPolicy::Available
);
assert_eq!(
marketplace.plugins[1].auth_policy,
PluginAuthPolicy::OnInstall
);
assert_eq!(
marketplace.plugins[2].id,
"uninstalled-plugin@codex-curated"
@@ -235,6 +252,14 @@ enabled = false
assert_eq!(marketplace.plugins[2].name, "uninstalled-plugin");
assert_eq!(marketplace.plugins[2].installed, false);
assert_eq!(marketplace.plugins[2].enabled, false);
assert_eq!(
marketplace.plugins[2].install_policy,
PluginInstallPolicy::Available
);
assert_eq!(
marketplace.plugins[2].auth_policy,
PluginAuthPolicy::OnInstall
);
Ok(())
}
@@ -418,8 +443,8 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
assert_eq!(plugin.id, "demo-plugin@codex-curated");
assert_eq!(plugin.installed, false);
assert_eq!(plugin.enabled, false);
assert_eq!(plugin.install_policy, Some(PluginInstallPolicy::Available));
assert_eq!(plugin.auth_policy, Some(PluginAuthPolicy::OnInstall));
assert_eq!(plugin.install_policy, PluginInstallPolicy::Available);
assert_eq!(plugin.auth_policy, PluginAuthPolicy::OnInstall);
let interface = plugin
.interface
.as_ref()
@@ -589,7 +614,9 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu
assert!(
codex_home
.path()
.join("plugins/cache/openai-curated/gmail/local")
.join(format!(
"plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}"
))
.is_dir()
);
assert!(
@@ -682,5 +709,10 @@ fn write_openai_curated_marketplace(
format!(r#"{{"name":"{plugin_name}"}}"#),
)?;
}
std::fs::create_dir_all(codex_home.join(".tmp"))?;
std::fs::write(
codex_home.join(".tmp/plugins.sha"),
format!("{TEST_CURATED_PLUGIN_SHA}\n"),
)?;
Ok(())
}
+209 -10
View File
@@ -13,6 +13,10 @@ use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE;
use codex_app_server::INVALID_PARAMS_ERROR_CODE;
use codex_app_server_protocol::ByteRange;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::CollabAgentState;
use codex_app_server_protocol::CollabAgentStatus;
use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::CollabAgentToolCallStatus;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionStatus;
@@ -68,6 +72,12 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
const TEST_ORIGINATOR: &str = "codex_vscode";
const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer.";
fn body_contains(req: &wiremock::Request, text: &str) -> bool {
String::from_utf8(req.body.clone())
.ok()
.is_some_and(|body| body.contains(text))
}
#[tokio::test]
async fn turn_start_sends_originator_header() -> Result<()> {
let responses = vec![create_final_assistant_message_sse_response("Done")?];
@@ -1152,11 +1162,6 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
.await??;
// Ensure we do NOT receive a CommandExecutionRequestApproval request before task completes
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
@@ -1462,7 +1467,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
@@ -1652,13 +1657,207 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}
#[tokio::test]
async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
const CHILD_PROMPT: &str = "child: do work";
const PARENT_PROMPT: &str = "spawn a child and continue";
const SPAWN_CALL_ID: &str = "spawn-call-1";
const REQUESTED_MODEL: &str = "gpt-5.1";
const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low;
let server = responses::start_mock_server().await;
let spawn_args = serde_json::to_string(&json!({
"message": CHILD_PROMPT,
"model": REQUESTED_MODEL,
"reasoning_effort": REQUESTED_REASONING_EFFORT,
}))?;
let _parent_turn = responses::mount_sse_once_match(
&server,
|req: &wiremock::Request| body_contains(req, PARENT_PROMPT),
responses::sse(vec![
responses::ev_response_created("resp-turn1-1"),
responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args),
responses::ev_completed("resp-turn1-1"),
]),
)
.await;
let _child_turn = responses::mount_sse_once_match(
&server,
|req: &wiremock::Request| {
body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID)
},
responses::sse(vec![
responses::ev_response_created("resp-child-1"),
responses::ev_assistant_message("msg-child-1", "child done"),
responses::ev_completed("resp-child-1"),
]),
)
.await;
let _parent_follow_up = responses::mount_sse_once_match(
&server,
|req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID),
responses::sse(vec![
responses::ev_response_created("resp-turn1-2"),
responses::ev_assistant_message("msg-turn1-2", "parent done"),
responses::ev_completed("resp-turn1-2"),
]),
)
.await;
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
&server.uri(),
"never",
&BTreeMap::from([(Feature::Collab, true)]),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.2-codex".to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: PARENT_PROMPT.to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
let spawn_started = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let started_notif = mcp
.read_stream_until_notification_message("item/started")
.await?;
let started: ItemStartedNotification =
serde_json::from_value(started_notif.params.expect("item/started params"))?;
if let ThreadItem::CollabAgentToolCall { id, .. } = &started.item
&& id == SPAWN_CALL_ID
{
return Ok::<ThreadItem, anyhow::Error>(started.item);
}
}
})
.await??;
assert_eq!(
spawn_started,
ThreadItem::CollabAgentToolCall {
id: SPAWN_CALL_ID.to_string(),
tool: CollabAgentTool::SpawnAgent,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: thread.id.clone(),
receiver_thread_ids: Vec::new(),
prompt: Some(CHILD_PROMPT.to_string()),
model: Some(REQUESTED_MODEL.to_string()),
reasoning_effort: Some(REQUESTED_REASONING_EFFORT),
agents_states: HashMap::new(),
metadata: None,
}
);
let spawn_completed = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let completed_notif = mcp
.read_stream_until_notification_message("item/completed")
.await?;
let completed: ItemCompletedNotification =
serde_json::from_value(completed_notif.params.expect("item/completed params"))?;
if let ThreadItem::CollabAgentToolCall { id, .. } = &completed.item
&& id == SPAWN_CALL_ID
{
return Ok::<ThreadItem, anyhow::Error>(completed.item);
}
}
})
.await??;
let ThreadItem::CollabAgentToolCall {
id,
tool,
status,
sender_thread_id,
receiver_thread_ids,
prompt,
model,
reasoning_effort,
agents_states,
metadata: _,
} = spawn_completed
else {
unreachable!("loop ensures we break on collab agent tool call items");
};
let receiver_thread_id = receiver_thread_ids
.first()
.cloned()
.expect("spawn completion should include child thread id");
assert_eq!(id, SPAWN_CALL_ID);
assert_eq!(tool, CollabAgentTool::SpawnAgent);
assert_eq!(status, CollabAgentToolCallStatus::Completed);
assert_eq!(sender_thread_id, thread.id);
assert_eq!(receiver_thread_ids, vec![receiver_thread_id.clone()]);
assert_eq!(prompt, Some(CHILD_PROMPT.to_string()));
assert_eq!(model, Some(REQUESTED_MODEL.to_string()));
assert_eq!(reasoning_effort, Some(REQUESTED_REASONING_EFFORT));
assert_eq!(
agents_states,
HashMap::from([(
receiver_thread_id,
CollabAgentState {
status: CollabAgentStatus::PendingInit,
message: None,
},
)])
);
let turn_completed = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let turn_completed_notif = mcp
.read_stream_until_notification_message("turn/completed")
.await?;
let turn_completed: TurnCompletedNotification = serde_json::from_value(
turn_completed_notif.params.expect("turn/completed params"),
)?;
if turn_completed.thread_id == thread.id && turn_completed.turn.id == turn.turn.id {
return Ok::<TurnCompletedNotification, anyhow::Error>(turn_completed);
}
}
})
.await??;
assert_eq!(turn_completed.thread_id, thread.id);
assert_eq!(turn_completed.turn.id, turn.turn.id);
Ok(())
}
#[tokio::test]
async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1783,7 +1982,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
@@ -1841,7 +2040,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
@@ -1993,7 +2192,7 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> {
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
@@ -303,7 +303,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
@@ -133,7 +133,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> {
let _task_started: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_started"),
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
@@ -236,7 +236,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> {
let _task_started: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_started"),
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
+1 -1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-connectors = { workspace = true }
codex-core = { workspace = true }
codex-utils-cli = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
@@ -17,7 +18,6 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
codex-git = { workspace = true }
urlencoding = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
+26 -360
View File
@@ -1,25 +1,18 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::LazyLock;
use std::sync::Mutex as StdMutex;
use codex_core::AuthManager;
use codex_core::config::Config;
use codex_core::token_data::TokenData;
use serde::Deserialize;
use std::collections::HashSet;
use std::time::Duration;
use std::time::Instant;
use crate::chatgpt_client::chatgpt_get_request_with_timeout;
use crate::chatgpt_token::get_chatgpt_token_data;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
use codex_core::connectors::AppBranding;
use codex_connectors::AllConnectorsCacheKey;
use codex_connectors::DirectoryListResponse;
pub use codex_core::connectors::AppInfo;
use codex_core::connectors::AppMetadata;
use codex_core::connectors::CONNECTORS_CACHE_TTL;
pub use codex_core::connectors::connector_display_label;
use codex_core::connectors::connector_install_url;
use codex_core::connectors::filter_disallowed_connectors;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
@@ -30,51 +23,8 @@ use codex_core::connectors::merge_plugin_apps;
pub use codex_core::connectors::with_app_enabled_state;
use codex_core::plugins::PluginsManager;
#[derive(Debug, Deserialize)]
struct DirectoryListResponse {
apps: Vec<DirectoryApp>,
#[serde(alias = "nextToken")]
next_token: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct DirectoryApp {
id: String,
name: String,
description: Option<String>,
#[serde(alias = "appMetadata")]
app_metadata: Option<AppMetadata>,
branding: Option<AppBranding>,
labels: Option<HashMap<String, String>>,
#[serde(alias = "logoUrl")]
logo_url: Option<String>,
#[serde(alias = "logoUrlDark")]
logo_url_dark: Option<String>,
#[serde(alias = "distributionChannel")]
distribution_channel: Option<String>,
visibility: Option<String>,
}
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Clone, PartialEq, Eq)]
struct AllConnectorsCacheKey {
chatgpt_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
}
#[derive(Clone)]
struct CachedAllConnectors {
key: AllConnectorsCacheKey,
expires_at: Instant,
connectors: Vec<AppInfo>,
}
static ALL_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAllConnectors>>> =
LazyLock::new(|| StdMutex::new(None));
async fn apps_enabled(config: &Config) -> bool {
let auth_manager = AuthManager::shared(
config.codex_home.clone(),
@@ -83,7 +33,6 @@ async fn apps_enabled(config: &Config) -> bool {
);
config.features.apps_enabled(Some(&auth_manager)).await
}
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
if !apps_enabled(config).await {
return Ok(Vec::new());
@@ -117,7 +66,7 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option<Vec<AppInfo>>
}
let token_data = get_chatgpt_token_data()?;
let cache_key = all_connectors_cache_key(config, &token_data);
read_cached_all_connectors(&cache_key).map(|connectors| {
codex_connectors::cached_all_connectors(&cache_key).map(|connectors| {
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config));
filter_disallowed_connectors(connectors)
})
@@ -136,76 +85,31 @@ pub async fn list_all_connectors_with_options(
let token_data =
get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?;
let cache_key = all_connectors_cache_key(config, &token_data);
if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) {
let connectors = merge_plugin_apps(cached_connectors, plugin_apps_for_config(config));
return Ok(filter_disallowed_connectors(connectors));
}
let mut apps = list_directory_connectors(config).await?;
if token_data.id_token.is_workspace_account() {
apps.extend(list_workspace_connectors(config).await?);
}
let mut connectors = merge_directory_apps(apps)
.into_iter()
.map(directory_app_to_app_info)
.collect::<Vec<_>>();
for connector in &mut connectors {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.name, &connector.id),
};
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
connectors.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
let connectors = filter_disallowed_connectors(connectors);
write_cached_all_connectors(cache_key, &connectors);
let connectors = codex_connectors::list_all_connectors_with_options(
cache_key,
token_data.id_token.is_workspace_account(),
force_refetch,
|path| async move {
chatgpt_get_request_with_timeout::<DirectoryListResponse>(
config,
path,
Some(DIRECTORY_CONNECTORS_TIMEOUT),
)
.await
},
)
.await?;
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config));
Ok(filter_disallowed_connectors(connectors))
}
fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConnectorsCacheKey {
AllConnectorsCacheKey {
chatgpt_base_url: config.chatgpt_base_url.clone(),
account_id: token_data.account_id.clone(),
chatgpt_user_id: token_data.id_token.chatgpt_user_id.clone(),
is_workspace_account: token_data.id_token.is_workspace_account(),
}
}
fn read_cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option<Vec<AppInfo>> {
let mut cache_guard = ALL_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let now = Instant::now();
if let Some(cached) = cache_guard.as_ref() {
if now < cached.expires_at && cached.key == *cache_key {
return Some(cached.connectors.clone());
}
if now >= cached.expires_at {
*cache_guard = None;
}
}
None
}
fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) {
let mut cache_guard = ALL_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*cache_guard = Some(CachedAllConnectors {
key: cache_key,
expires_at: Instant::now() + CONNECTORS_CACHE_TTL,
connectors: connectors.to_vec(),
});
AllConnectorsCacheKey::new(
config.chatgpt_base_url.clone(),
token_data.account_id.clone(),
token_data.id_token.chatgpt_user_id.clone(),
token_data.id_token.is_workspace_account(),
)
}
fn plugin_apps_for_config(config: &Config) -> Vec<codex_core::plugins::AppConnectorId> {
@@ -235,248 +139,10 @@ pub fn merge_connectors_with_accessible(
filter_disallowed_connectors(merged)
}
async fn list_directory_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
let mut apps = Vec::new();
let mut next_token: Option<String> = None;
loop {
let path = match next_token.as_deref() {
Some(token) => {
let encoded_token = urlencoding::encode(token);
format!(
"/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true"
)
}
None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(),
};
let response: DirectoryListResponse =
chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT))
.await?;
apps.extend(
response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app)),
);
next_token = response
.next_token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
if next_token.is_none() {
break;
}
}
Ok(apps)
}
async fn list_workspace_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
let response: anyhow::Result<DirectoryListResponse> = chatgpt_get_request_with_timeout(
config,
"/connectors/directory/list_workspace?external_logos=true".to_string(),
Some(DIRECTORY_CONNECTORS_TIMEOUT),
)
.await;
match response {
Ok(response) => Ok(response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app))
.collect()),
Err(_) => Ok(Vec::new()),
}
}
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
for app in apps {
if let Some(existing) = merged.get_mut(&app.id) {
merge_directory_app(existing, app);
} else {
merged.insert(app.id.clone(), app);
}
}
merged.into_values().collect()
}
fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
let DirectoryApp {
id: _,
name,
description,
app_metadata,
branding,
labels,
logo_url,
logo_url_dark,
distribution_channel,
visibility: _,
} = incoming;
let incoming_name_is_empty = name.trim().is_empty();
if existing.name.trim().is_empty() && !incoming_name_is_empty {
existing.name = name;
}
let incoming_description_present = description
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if incoming_description_present {
existing.description = description;
}
if existing.logo_url.is_none() && logo_url.is_some() {
existing.logo_url = logo_url;
}
if existing.logo_url_dark.is_none() && logo_url_dark.is_some() {
existing.logo_url_dark = logo_url_dark;
}
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
existing.distribution_channel = distribution_channel;
}
if let Some(incoming_branding) = branding {
if let Some(existing_branding) = existing.branding.as_mut() {
if existing_branding.category.is_none() && incoming_branding.category.is_some() {
existing_branding.category = incoming_branding.category;
}
if existing_branding.developer.is_none() && incoming_branding.developer.is_some() {
existing_branding.developer = incoming_branding.developer;
}
if existing_branding.website.is_none() && incoming_branding.website.is_some() {
existing_branding.website = incoming_branding.website;
}
if existing_branding.privacy_policy.is_none()
&& incoming_branding.privacy_policy.is_some()
{
existing_branding.privacy_policy = incoming_branding.privacy_policy;
}
if existing_branding.terms_of_service.is_none()
&& incoming_branding.terms_of_service.is_some()
{
existing_branding.terms_of_service = incoming_branding.terms_of_service;
}
if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app {
existing_branding.is_discoverable_app = true;
}
} else {
existing.branding = Some(incoming_branding);
}
}
if let Some(incoming_app_metadata) = app_metadata {
if let Some(existing_app_metadata) = existing.app_metadata.as_mut() {
if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() {
existing_app_metadata.review = incoming_app_metadata.review;
}
if existing_app_metadata.categories.is_none()
&& incoming_app_metadata.categories.is_some()
{
existing_app_metadata.categories = incoming_app_metadata.categories;
}
if existing_app_metadata.sub_categories.is_none()
&& incoming_app_metadata.sub_categories.is_some()
{
existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories;
}
if existing_app_metadata.seo_description.is_none()
&& incoming_app_metadata.seo_description.is_some()
{
existing_app_metadata.seo_description = incoming_app_metadata.seo_description;
}
if existing_app_metadata.screenshots.is_none()
&& incoming_app_metadata.screenshots.is_some()
{
existing_app_metadata.screenshots = incoming_app_metadata.screenshots;
}
if existing_app_metadata.developer.is_none()
&& incoming_app_metadata.developer.is_some()
{
existing_app_metadata.developer = incoming_app_metadata.developer;
}
if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() {
existing_app_metadata.version = incoming_app_metadata.version;
}
if existing_app_metadata.version_id.is_none()
&& incoming_app_metadata.version_id.is_some()
{
existing_app_metadata.version_id = incoming_app_metadata.version_id;
}
if existing_app_metadata.version_notes.is_none()
&& incoming_app_metadata.version_notes.is_some()
{
existing_app_metadata.version_notes = incoming_app_metadata.version_notes;
}
if existing_app_metadata.first_party_type.is_none()
&& incoming_app_metadata.first_party_type.is_some()
{
existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type;
}
if existing_app_metadata.first_party_requires_install.is_none()
&& incoming_app_metadata.first_party_requires_install.is_some()
{
existing_app_metadata.first_party_requires_install =
incoming_app_metadata.first_party_requires_install;
}
if existing_app_metadata
.show_in_composer_when_unlinked
.is_none()
&& incoming_app_metadata
.show_in_composer_when_unlinked
.is_some()
{
existing_app_metadata.show_in_composer_when_unlinked =
incoming_app_metadata.show_in_composer_when_unlinked;
}
} else {
existing.app_metadata = Some(incoming_app_metadata);
}
}
if existing.labels.is_none() && labels.is_some() {
existing.labels = labels;
}
}
fn is_hidden_directory_app(app: &DirectoryApp) -> bool {
matches!(app.visibility.as_deref(), Some("HIDDEN"))
}
fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
AppInfo {
id: app.id,
name: app.name,
description: app.description,
logo_url: app.logo_url,
logo_url_dark: app.logo_url_dark,
distribution_channel: app.distribution_channel,
branding: app.branding,
app_metadata: app.app_metadata,
labels: app.labels,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}
}
fn normalize_connector_name(name: &str, connector_id: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
connector_id.to_string()
} else {
trimmed.to_string()
}
}
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::connectors::connector_install_url;
use pretty_assertions::assert_eq;
fn app(id: &str) -> AppInfo {
+2 -3
View File
@@ -250,19 +250,18 @@ async fn run_command_under_sandbox(
.await?
}
SandboxType::Landlock => {
use codex_core::features::Feature;
#[expect(clippy::expect_used)]
let codex_linux_sandbox_exe = config
.codex_linux_sandbox_exe
.expect("codex-linux-sandbox executable not found");
let use_bwrap_sandbox = config.features.enabled(Feature::UseLinuxSandboxBwrap);
let use_legacy_landlock = config.features.use_legacy_landlock();
spawn_command_under_linux_sandbox(
codex_linux_sandbox_exe,
command,
cwd,
config.permissions.sandbox_policy.get(),
sandbox_policy_cwd.as_path(),
use_bwrap_sandbox,
use_legacy_landlock,
stdio_policy,
network.as_ref(),
env,
+1 -1
View File
@@ -32,7 +32,7 @@ pub struct LandlockCommand {
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
/// Full command args to run under landlock.
/// Full command args to run under the Linux sandbox.
#[arg(trailing_var_arg = true)]
pub command: Vec<String>,
}
+14 -1
View File
@@ -239,7 +239,7 @@ enum SandboxCommand {
#[clap(visible_alias = "seatbelt")]
Macos(SeatbeltCommand),
/// Run a command under Landlock+seccomp (Linux only).
/// Run a command under the Linux sandbox (bubblewrap by default).
#[clap(visible_alias = "landlock")]
Linux(LandlockCommand),
@@ -1516,6 +1516,19 @@ mod tests {
);
}
#[test]
fn feature_toggles_accept_legacy_linux_sandbox_flag() {
let toggles = FeatureToggles {
enable: vec!["use_linux_sandbox_bwrap".to_string()],
disable: Vec::new(),
};
let overrides = toggles.to_overrides().expect("valid features");
assert_eq!(
overrides,
vec!["features.use_linux_sandbox_bwrap=true".to_string(),]
);
}
#[test]
fn feature_toggles_unknown_feature_errors() {
let toggles = FeatureToggles {
@@ -21,6 +21,7 @@ pub(crate) fn attach_item_ids(payload_json: &mut Value, original_items: &[Respon
| ResponseItem::Message { id: Some(id), .. }
| ResponseItem::WebSearchCall { id: Some(id), .. }
| ResponseItem::FunctionCall { id: Some(id), .. }
| ResponseItem::ToolSearchCall { id: Some(id), .. }
| ResponseItem::LocalShellCall { id: Some(id), .. }
| ResponseItem::CustomToolCall { id: Some(id), .. } = item
{
+36
View File
@@ -641,6 +641,42 @@ mod tests {
}
}
#[tokio::test]
async fn parses_tool_search_call_items() {
let events = run_sse(vec![
json!({
"type": "response.output_item.done",
"item": {
"type": "tool_search_call",
"call_id": "search-1",
"execution": "client",
"arguments": {
"query": "calendar create",
"limit": 1
}
}
}),
json!({
"type": "response.completed",
"response": { "id": "resp1" }
}),
])
.await;
assert_eq!(events.len(), 2);
assert_matches!(
&events[0],
ResponseEvent::OutputItemDone(ResponseItem::ToolSearchCall {
call_id,
execution,
arguments,
..
}) if call_id.as_deref() == Some("search-1")
&& execution == "client"
&& arguments == &json!({"query": "calendar create", "limit": 1})
);
}
#[tokio::test]
async fn emits_completed_without_stream_end() {
let completed = json!({
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "connectors",
crate_name = "codex_connectors",
)
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "codex-connectors"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-app-server-protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
urlencoding = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+534
View File
@@ -0,0 +1,534 @@
use std::collections::HashMap;
use std::future::Future;
use std::sync::LazyLock;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use std::time::Instant;
use codex_app_server_protocol::AppBranding;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppMetadata;
use serde::Deserialize;
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AllConnectorsCacheKey {
chatgpt_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
}
impl AllConnectorsCacheKey {
pub fn new(
chatgpt_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
) -> Self {
Self {
chatgpt_base_url,
account_id,
chatgpt_user_id,
is_workspace_account,
}
}
}
#[derive(Clone)]
struct CachedAllConnectors {
key: AllConnectorsCacheKey,
expires_at: Instant,
connectors: Vec<AppInfo>,
}
static ALL_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAllConnectors>>> =
LazyLock::new(|| StdMutex::new(None));
#[derive(Debug, Deserialize)]
pub struct DirectoryListResponse {
apps: Vec<DirectoryApp>,
#[serde(alias = "nextToken")]
next_token: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DirectoryApp {
id: String,
name: String,
description: Option<String>,
#[serde(alias = "appMetadata")]
app_metadata: Option<AppMetadata>,
branding: Option<AppBranding>,
labels: Option<HashMap<String, String>>,
#[serde(alias = "logoUrl")]
logo_url: Option<String>,
#[serde(alias = "logoUrlDark")]
logo_url_dark: Option<String>,
#[serde(alias = "distributionChannel")]
distribution_channel: Option<String>,
visibility: Option<String>,
}
pub fn cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option<Vec<AppInfo>> {
let mut cache_guard = ALL_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let now = Instant::now();
if let Some(cached) = cache_guard.as_ref() {
if now < cached.expires_at && cached.key == *cache_key {
return Some(cached.connectors.clone());
}
if now >= cached.expires_at {
*cache_guard = None;
}
}
None
}
pub async fn list_all_connectors_with_options<F, Fut>(
cache_key: AllConnectorsCacheKey,
is_workspace_account: bool,
force_refetch: bool,
mut fetch_page: F,
) -> anyhow::Result<Vec<AppInfo>>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
{
if !force_refetch && let Some(cached_connectors) = cached_all_connectors(&cache_key) {
return Ok(cached_connectors);
}
let mut apps = list_directory_connectors(&mut fetch_page).await?;
if is_workspace_account {
apps.extend(list_workspace_connectors(&mut fetch_page).await?);
}
let mut connectors = merge_directory_apps(apps)
.into_iter()
.map(directory_app_to_app_info)
.collect::<Vec<_>>();
for connector in &mut connectors {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.name, &connector.id),
};
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
connectors.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
write_cached_all_connectors(cache_key, &connectors);
Ok(connectors)
}
fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) {
let mut cache_guard = ALL_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*cache_guard = Some(CachedAllConnectors {
key: cache_key,
expires_at: Instant::now() + CONNECTORS_CACHE_TTL,
connectors: connectors.to_vec(),
});
}
async fn list_directory_connectors<F, Fut>(fetch_page: &mut F) -> anyhow::Result<Vec<DirectoryApp>>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
{
let mut apps = Vec::new();
let mut next_token: Option<String> = None;
loop {
let path = match next_token.as_deref() {
Some(token) => {
let encoded_token = urlencoding::encode(token);
format!(
"/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true"
)
}
None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(),
};
let response = fetch_page(path).await?;
apps.extend(
response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app)),
);
next_token = response
.next_token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
if next_token.is_none() {
break;
}
}
Ok(apps)
}
async fn list_workspace_connectors<F, Fut>(fetch_page: &mut F) -> anyhow::Result<Vec<DirectoryApp>>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
{
let response =
fetch_page("/connectors/directory/list_workspace?external_logos=true".to_string()).await;
match response {
Ok(response) => Ok(response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app))
.collect()),
Err(_) => Ok(Vec::new()),
}
}
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
for app in apps {
if let Some(existing) = merged.get_mut(&app.id) {
merge_directory_app(existing, app);
} else {
merged.insert(app.id.clone(), app);
}
}
merged.into_values().collect()
}
fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
let DirectoryApp {
id: _,
name,
description,
app_metadata,
branding,
labels,
logo_url,
logo_url_dark,
distribution_channel,
visibility: _,
} = incoming;
let incoming_name_is_empty = name.trim().is_empty();
if existing.name.trim().is_empty() && !incoming_name_is_empty {
existing.name = name;
}
let incoming_description_present = description
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if incoming_description_present {
existing.description = description;
}
if existing.logo_url.is_none() && logo_url.is_some() {
existing.logo_url = logo_url;
}
if existing.logo_url_dark.is_none() && logo_url_dark.is_some() {
existing.logo_url_dark = logo_url_dark;
}
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
existing.distribution_channel = distribution_channel;
}
if let Some(incoming_branding) = branding {
if let Some(existing_branding) = existing.branding.as_mut() {
if existing_branding.category.is_none() && incoming_branding.category.is_some() {
existing_branding.category = incoming_branding.category;
}
if existing_branding.developer.is_none() && incoming_branding.developer.is_some() {
existing_branding.developer = incoming_branding.developer;
}
if existing_branding.website.is_none() && incoming_branding.website.is_some() {
existing_branding.website = incoming_branding.website;
}
if existing_branding.privacy_policy.is_none()
&& incoming_branding.privacy_policy.is_some()
{
existing_branding.privacy_policy = incoming_branding.privacy_policy;
}
if existing_branding.terms_of_service.is_none()
&& incoming_branding.terms_of_service.is_some()
{
existing_branding.terms_of_service = incoming_branding.terms_of_service;
}
if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app {
existing_branding.is_discoverable_app = true;
}
} else {
existing.branding = Some(incoming_branding);
}
}
if let Some(incoming_app_metadata) = app_metadata {
if let Some(existing_app_metadata) = existing.app_metadata.as_mut() {
if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() {
existing_app_metadata.review = incoming_app_metadata.review;
}
if existing_app_metadata.categories.is_none()
&& incoming_app_metadata.categories.is_some()
{
existing_app_metadata.categories = incoming_app_metadata.categories;
}
if existing_app_metadata.sub_categories.is_none()
&& incoming_app_metadata.sub_categories.is_some()
{
existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories;
}
if existing_app_metadata.seo_description.is_none()
&& incoming_app_metadata.seo_description.is_some()
{
existing_app_metadata.seo_description = incoming_app_metadata.seo_description;
}
if existing_app_metadata.screenshots.is_none()
&& incoming_app_metadata.screenshots.is_some()
{
existing_app_metadata.screenshots = incoming_app_metadata.screenshots;
}
if existing_app_metadata.developer.is_none()
&& incoming_app_metadata.developer.is_some()
{
existing_app_metadata.developer = incoming_app_metadata.developer;
}
if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() {
existing_app_metadata.version = incoming_app_metadata.version;
}
if existing_app_metadata.version_id.is_none()
&& incoming_app_metadata.version_id.is_some()
{
existing_app_metadata.version_id = incoming_app_metadata.version_id;
}
if existing_app_metadata.version_notes.is_none()
&& incoming_app_metadata.version_notes.is_some()
{
existing_app_metadata.version_notes = incoming_app_metadata.version_notes;
}
if existing_app_metadata.first_party_type.is_none()
&& incoming_app_metadata.first_party_type.is_some()
{
existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type;
}
if existing_app_metadata.first_party_requires_install.is_none()
&& incoming_app_metadata.first_party_requires_install.is_some()
{
existing_app_metadata.first_party_requires_install =
incoming_app_metadata.first_party_requires_install;
}
if existing_app_metadata
.show_in_composer_when_unlinked
.is_none()
&& incoming_app_metadata
.show_in_composer_when_unlinked
.is_some()
{
existing_app_metadata.show_in_composer_when_unlinked =
incoming_app_metadata.show_in_composer_when_unlinked;
}
} else {
existing.app_metadata = Some(incoming_app_metadata);
}
}
if existing.labels.is_none() && labels.is_some() {
existing.labels = labels;
}
}
fn is_hidden_directory_app(app: &DirectoryApp) -> bool {
matches!(app.visibility.as_deref(), Some("HIDDEN"))
}
fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
AppInfo {
id: app.id,
name: app.name,
description: app.description,
logo_url: app.logo_url,
logo_url_dark: app.logo_url_dark,
distribution_channel: app.distribution_channel,
branding: app.branding,
app_metadata: app.app_metadata,
labels: app.labels,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}
}
fn connector_install_url(name: &str, connector_id: &str) -> String {
let slug = connector_name_slug(name);
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
}
fn connector_name_slug(name: &str) -> String {
let mut normalized = String::with_capacity(name.len());
for character in name.chars() {
if character.is_ascii_alphanumeric() {
normalized.push(character.to_ascii_lowercase());
} else {
normalized.push('-');
}
}
let normalized = normalized.trim_matches('-');
if normalized.is_empty() {
"app".to_string()
} else {
normalized.to_string()
}
}
fn normalize_connector_name(name: &str, connector_id: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
connector_id.to_string()
} else {
trimmed.to_string()
}
}
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
fn cache_key(id: &str) -> AllConnectorsCacheKey {
AllConnectorsCacheKey::new(
"https://chatgpt.example".to_string(),
Some(format!("account-{id}")),
Some(format!("user-{id}")),
true,
)
}
fn app(id: &str, name: &str) -> DirectoryApp {
DirectoryApp {
id: id.to_string(),
name: name.to_string(),
description: None,
app_metadata: None,
branding: None,
labels: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
visibility: None,
}
}
#[tokio::test]
async fn list_all_connectors_uses_shared_cache() -> anyhow::Result<()> {
let calls = Arc::new(AtomicUsize::new(0));
let call_counter = Arc::clone(&calls);
let key = cache_key("shared");
let first = list_all_connectors_with_options(key.clone(), false, false, move |_path| {
let call_counter = Arc::clone(&call_counter);
async move {
call_counter.fetch_add(1, Ordering::SeqCst);
Ok(DirectoryListResponse {
apps: vec![app("alpha", "Alpha")],
next_token: None,
})
}
})
.await?;
let second = list_all_connectors_with_options(key, false, false, move |_path| async move {
anyhow::bail!("cache should have been used");
})
.await?;
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(first, second);
Ok(())
}
#[tokio::test]
async fn list_all_connectors_merges_and_normalizes_directory_apps() -> anyhow::Result<()> {
let key = cache_key("merged");
let calls = Arc::new(AtomicUsize::new(0));
let call_counter = Arc::clone(&calls);
let connectors = list_all_connectors_with_options(key, true, true, move |path| {
let call_counter = Arc::clone(&call_counter);
async move {
call_counter.fetch_add(1, Ordering::SeqCst);
if path.starts_with("/connectors/directory/list_workspace") {
Ok(DirectoryListResponse {
apps: vec![
DirectoryApp {
description: Some("Merged description".to_string()),
branding: Some(AppBranding {
category: Some("calendar".to_string()),
developer: None,
website: None,
privacy_policy: None,
terms_of_service: None,
is_discoverable_app: true,
}),
..app("alpha", "")
},
DirectoryApp {
visibility: Some("HIDDEN".to_string()),
..app("hidden", "Hidden")
},
],
next_token: None,
})
} else {
Ok(DirectoryListResponse {
apps: vec![app("alpha", " Alpha "), app("beta", "Beta")],
next_token: None,
})
}
}
})
.await?;
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert_eq!(connectors.len(), 2);
assert_eq!(connectors[0].id, "alpha");
assert_eq!(connectors[0].name, "Alpha");
assert_eq!(
connectors[0].description.as_deref(),
Some("Merged description")
);
assert_eq!(
connectors[0].install_url.as_deref(),
Some("https://chatgpt.com/apps/alpha/alpha")
);
assert_eq!(
connectors[0]
.branding
.as_ref()
.and_then(|branding| branding.category.as_deref()),
Some("calendar")
);
assert_eq!(connectors[1].id, "beta");
assert_eq!(connectors[1].name, "Beta");
Ok(())
}
}
+1
View File
@@ -32,6 +32,7 @@ codex-app-server-protocol = { workspace = true }
codex-apply-patch = { workspace = true }
codex-async-utils = { workspace = true }
codex-client = { workspace = true }
codex-connectors = { workspace = true }
codex-config = { workspace = true }
codex-shell-command = { workspace = true }
codex-skills = { workspace = true }
+15 -2
View File
@@ -471,12 +471,18 @@
"tool_call_mcp_elicitation": {
"type": "boolean"
},
"tool_suggest": {
"type": "boolean"
},
"undo": {
"type": "boolean"
},
"unified_exec": {
"type": "boolean"
},
"use_legacy_landlock": {
"type": "boolean"
},
"use_linux_sandbox_bwrap": {
"type": "boolean"
},
@@ -597,10 +603,11 @@
"type": "object"
},
"FileSystemAccessMode": {
"description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.",
"enum": [
"none",
"read",
"write"
"write",
"none"
],
"type": "string"
},
@@ -1975,12 +1982,18 @@
"tool_call_mcp_elicitation": {
"type": "boolean"
},
"tool_suggest": {
"type": "boolean"
},
"undo": {
"type": "boolean"
},
"unified_exec": {
"type": "boolean"
},
"use_legacy_landlock": {
"type": "boolean"
},
"use_linux_sandbox_bwrap": {
"type": "boolean"
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -246
View File
@@ -222,249 +222,5 @@ impl Drop for SpawnReservation {
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
#[test]
fn format_agent_nickname_adds_ordinals_after_reset() {
assert_eq!(format_agent_nickname("Plato", 0), "Plato");
assert_eq!(format_agent_nickname("Plato", 1), "Plato the 2nd");
assert_eq!(format_agent_nickname("Plato", 2), "Plato the 3rd");
assert_eq!(format_agent_nickname("Plato", 10), "Plato the 11th");
assert_eq!(format_agent_nickname("Plato", 20), "Plato the 21st");
}
#[test]
fn session_depth_defaults_to_zero_for_root_sources() {
assert_eq!(session_depth(&SessionSource::Cli), 0);
}
#[test]
fn thread_spawn_depth_increments_and_enforces_limit() {
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 1,
agent_nickname: None,
agent_role: None,
});
let child_depth = next_thread_spawn_depth(&session_source);
assert_eq!(child_depth, 2);
assert!(exceeds_thread_spawn_depth_limit(child_depth, 1));
}
#[test]
fn non_thread_spawn_subagents_default_to_depth_zero() {
let session_source = SessionSource::SubAgent(SubAgentSource::Review);
assert_eq!(session_depth(&session_source), 0);
assert_eq!(next_thread_spawn_depth(&session_source), 1);
assert!(!exceeds_thread_spawn_depth_limit(1, 1));
}
#[test]
fn reservation_drop_releases_slot() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
drop(reservation);
let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot released");
drop(reservation);
}
#[test]
fn commit_holds_slot_until_release() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
let thread_id = ThreadId::new();
reservation.commit(thread_id);
let err = match guards.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
guards.release_spawned_thread(thread_id);
let reservation = guards
.reserve_spawn_slot(Some(1))
.expect("slot released after thread removal");
drop(reservation);
}
#[test]
fn release_ignores_unknown_thread_id() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
let thread_id = ThreadId::new();
reservation.commit(thread_id);
guards.release_spawned_thread(ThreadId::new());
let err = match guards.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should still be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
guards.release_spawned_thread(thread_id);
let reservation = guards
.reserve_spawn_slot(Some(1))
.expect("slot released after real thread removal");
drop(reservation);
}
#[test]
fn release_is_idempotent_for_registered_threads() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
let first_id = ThreadId::new();
reservation.commit(first_id);
guards.release_spawned_thread(first_id);
let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot reused");
let second_id = ThreadId::new();
reservation.commit(second_id);
guards.release_spawned_thread(first_id);
let err = match guards.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should still be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
guards.release_spawned_thread(second_id);
let reservation = guards
.reserve_spawn_slot(Some(1))
.expect("slot released after second thread removal");
drop(reservation);
}
#[test]
fn failed_spawn_keeps_nickname_marked_used() {
let guards = Arc::new(Guards::default());
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname(&["alpha"])
.expect("reserve agent name");
assert_eq!(agent_nickname, "alpha");
drop(reservation);
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname(&["alpha", "beta"])
.expect("unused name should still be preferred");
assert_eq!(agent_nickname, "beta");
}
#[test]
fn agent_nickname_resets_used_pool_when_exhausted() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["alpha"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "alpha");
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["alpha"])
.expect("name should be reused after pool reset");
assert_eq!(second_name, "alpha the 2nd");
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
#[test]
fn released_nickname_stays_used_until_pool_reset() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["alpha"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "alpha");
guards.release_spawned_thread(first_id);
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["alpha", "beta"])
.expect("released name should still be marked used");
assert_eq!(second_name, "beta");
let second_id = ThreadId::new();
second.commit(second_id);
guards.release_spawned_thread(second_id);
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
let third_name = third
.reserve_agent_nickname(&["alpha", "beta"])
.expect("pool reset should permit a duplicate");
let expected_names =
HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]);
assert!(expected_names.contains(&third_name));
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
#[test]
fn repeated_resets_advance_the_ordinal_suffix() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["Plato"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "Plato");
guards.release_spawned_thread(first_id);
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["Plato"])
.expect("reserve second agent name");
let second_id = ThreadId::new();
second.commit(second_id);
assert_eq!(second_name, "Plato the 2nd");
guards.release_spawned_thread(second_id);
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
let third_name = third
.reserve_agent_nickname(&["Plato"])
.expect("reserve third agent name");
assert_eq!(third_name, "Plato the 3rd");
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 2);
}
}
#[path = "guards_tests.rs"]
mod tests;
+243
View File
@@ -0,0 +1,243 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
#[test]
fn format_agent_nickname_adds_ordinals_after_reset() {
assert_eq!(format_agent_nickname("Plato", 0), "Plato");
assert_eq!(format_agent_nickname("Plato", 1), "Plato the 2nd");
assert_eq!(format_agent_nickname("Plato", 2), "Plato the 3rd");
assert_eq!(format_agent_nickname("Plato", 10), "Plato the 11th");
assert_eq!(format_agent_nickname("Plato", 20), "Plato the 21st");
}
#[test]
fn session_depth_defaults_to_zero_for_root_sources() {
assert_eq!(session_depth(&SessionSource::Cli), 0);
}
#[test]
fn thread_spawn_depth_increments_and_enforces_limit() {
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 1,
agent_nickname: None,
agent_role: None,
});
let child_depth = next_thread_spawn_depth(&session_source);
assert_eq!(child_depth, 2);
assert!(exceeds_thread_spawn_depth_limit(child_depth, 1));
}
#[test]
fn non_thread_spawn_subagents_default_to_depth_zero() {
let session_source = SessionSource::SubAgent(SubAgentSource::Review);
assert_eq!(session_depth(&session_source), 0);
assert_eq!(next_thread_spawn_depth(&session_source), 1);
assert!(!exceeds_thread_spawn_depth_limit(1, 1));
}
#[test]
fn reservation_drop_releases_slot() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
drop(reservation);
let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot released");
drop(reservation);
}
#[test]
fn commit_holds_slot_until_release() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
let thread_id = ThreadId::new();
reservation.commit(thread_id);
let err = match guards.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
guards.release_spawned_thread(thread_id);
let reservation = guards
.reserve_spawn_slot(Some(1))
.expect("slot released after thread removal");
drop(reservation);
}
#[test]
fn release_ignores_unknown_thread_id() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
let thread_id = ThreadId::new();
reservation.commit(thread_id);
guards.release_spawned_thread(ThreadId::new());
let err = match guards.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should still be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
guards.release_spawned_thread(thread_id);
let reservation = guards
.reserve_spawn_slot(Some(1))
.expect("slot released after real thread removal");
drop(reservation);
}
#[test]
fn release_is_idempotent_for_registered_threads() {
let guards = Arc::new(Guards::default());
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
let first_id = ThreadId::new();
reservation.commit(first_id);
guards.release_spawned_thread(first_id);
let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot reused");
let second_id = ThreadId::new();
reservation.commit(second_id);
guards.release_spawned_thread(first_id);
let err = match guards.reserve_spawn_slot(Some(1)) {
Ok(_) => panic!("limit should still be enforced"),
Err(err) => err,
};
let CodexErr::AgentLimitReached { max_threads } = err else {
panic!("expected CodexErr::AgentLimitReached");
};
assert_eq!(max_threads, 1);
guards.release_spawned_thread(second_id);
let reservation = guards
.reserve_spawn_slot(Some(1))
.expect("slot released after second thread removal");
drop(reservation);
}
#[test]
fn failed_spawn_keeps_nickname_marked_used() {
let guards = Arc::new(Guards::default());
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname(&["alpha"])
.expect("reserve agent name");
assert_eq!(agent_nickname, "alpha");
drop(reservation);
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname(&["alpha", "beta"])
.expect("unused name should still be preferred");
assert_eq!(agent_nickname, "beta");
}
#[test]
fn agent_nickname_resets_used_pool_when_exhausted() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["alpha"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "alpha");
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["alpha"])
.expect("name should be reused after pool reset");
assert_eq!(second_name, "alpha the 2nd");
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
#[test]
fn released_nickname_stays_used_until_pool_reset() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["alpha"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "alpha");
guards.release_spawned_thread(first_id);
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["alpha", "beta"])
.expect("released name should still be marked used");
assert_eq!(second_name, "beta");
let second_id = ThreadId::new();
second.commit(second_id);
guards.release_spawned_thread(second_id);
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
let third_name = third
.reserve_agent_nickname(&["alpha", "beta"])
.expect("pool reset should permit a duplicate");
let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]);
assert!(expected_names.contains(&third_name));
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
#[test]
fn repeated_resets_advance_the_ordinal_suffix() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["Plato"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "Plato");
guards.release_spawned_thread(first_id);
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["Plato"])
.expect("reserve second agent name");
let second_id = ThreadId::new();
second.commit(second_id);
assert_eq!(second_name, "Plato the 2nd");
guards.release_spawned_thread(second_id);
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
let third_name = third
.reserve_agent_nickname(&["Plato"])
.expect("reserve third agent name");
assert_eq!(third_name, "Plato the 3rd");
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 2);
}
+2 -682
View File
@@ -309,685 +309,5 @@ Rules:
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::plugins::PluginsManager;
use crate::skills::SkillsManager;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
async fn test_config_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
) -> (TempDir, Config) {
let home = TempDir::new().expect("create temp dir");
let home_path = home.path().to_path_buf();
let config = ConfigBuilder::default()
.codex_home(home_path.clone())
.cli_overrides(cli_overrides)
.fallback_cwd(Some(home_path))
.build()
.await
.expect("load test config");
(home, config)
}
async fn write_role_config(home: &TempDir, name: &str, contents: &str) -> PathBuf {
let role_path = home.path().join(name);
tokio::fs::write(&role_path, contents)
.await
.expect("write role config");
role_path
}
fn session_flags_layer_count(config: &Config) -> usize {
config
.config_layer_stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
.into_iter()
.filter(|layer| layer.name == ConfigLayerSource::SessionFlags)
.count()
}
#[tokio::test]
async fn apply_role_defaults_to_default_and_leaves_config_unchanged() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let before = config.clone();
apply_role_to_config(&mut config, None)
.await
.expect("default role should apply");
assert_eq!(before, config);
}
#[tokio::test]
async fn apply_role_returns_error_for_unknown_role() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let err = apply_role_to_config(&mut config, Some("missing-role"))
.await
.expect_err("unknown role should fail");
assert_eq!(err, "unknown agent_type 'missing-role'");
}
#[tokio::test]
#[ignore = "No role requiring it for now"]
async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let before_layers = session_flags_layer_count(&config);
apply_role_to_config(&mut config, Some("explorer"))
.await
.expect("explorer role should apply");
assert_eq!(config.model.as_deref(), Some("gpt-5.1-codex-mini"));
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::Medium));
assert_eq!(session_flags_layer_count(&config), before_layers + 1);
}
#[tokio::test]
async fn apply_role_returns_unavailable_for_missing_user_role_file() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(PathBuf::from("/path/does/not/exist.toml")),
nickname_candidates: None,
},
);
let err = apply_role_to_config(&mut config, Some("custom"))
.await
.expect_err("missing role file should fail");
assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR);
}
#[tokio::test]
async fn apply_role_returns_unavailable_for_invalid_user_role_toml() {
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let role_path = write_role_config(&home, "invalid-role.toml", "model = [").await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
let err = apply_role_to_config(&mut config, Some("custom"))
.await
.expect_err("invalid role file should fail");
assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR);
}
#[tokio::test]
async fn apply_role_ignores_agent_metadata_fields_in_user_role_file() {
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let role_path = write_role_config(
&home,
"metadata-role.toml",
r#"
name = "archivist"
description = "Role metadata"
nickname_candidates = ["Hypatia"]
developer_instructions = "Stay focused"
model = "role-model"
"#,
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.model.as_deref(), Some("role-model"));
}
#[tokio::test]
async fn apply_role_preserves_unspecified_keys() {
let (home, mut config) = test_config_with_cli_overrides(vec![(
"model".to_string(),
TomlValue::String("base-model".to_string()),
)])
.await;
config.codex_linux_sandbox_exe = Some(PathBuf::from("/tmp/codex-linux-sandbox"));
config.main_execve_wrapper_exe = Some(PathBuf::from("/tmp/codex-execve-wrapper"));
let role_path = write_role_config(
&home,
"effort-only.toml",
"developer_instructions = \"Stay focused\"\nmodel_reasoning_effort = \"high\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.model.as_deref(), Some("base-model"));
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(
config.codex_linux_sandbox_exe,
Some(PathBuf::from("/tmp/codex-linux-sandbox"))
);
assert_eq!(
config.main_execve_wrapper_exe,
Some(PathBuf::from("/tmp/codex-execve-wrapper"))
);
}
#[tokio::test]
async fn apply_role_preserves_active_profile_and_model_provider() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.test-provider]
name = "Test Provider"
base_url = "https://example.com/v1"
env_key = "TEST_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.test-profile]
model_provider = "test-provider"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("test-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
let role_path = write_role_config(
&home,
"empty-role.toml",
"developer_instructions = \"Stay focused\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.active_profile.as_deref(), Some("test-profile"));
assert_eq!(config.model_provider_id, "test-provider");
assert_eq!(config.model_provider.name, "Test Provider");
}
#[tokio::test]
async fn apply_role_uses_role_profile_instead_of_current_profile() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.base-provider]
name = "Base Provider"
base_url = "https://base.example.com/v1"
env_key = "BASE_PROVIDER_API_KEY"
wire_api = "responses"
[model_providers.role-provider]
name = "Role Provider"
base_url = "https://role.example.com/v1"
env_key = "ROLE_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.base-profile]
model_provider = "base-provider"
[profiles.role-profile]
model_provider = "role-provider"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("base-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
let role_path = write_role_config(
&home,
"profile-role.toml",
"developer_instructions = \"Stay focused\"\nprofile = \"role-profile\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.active_profile.as_deref(), Some("role-profile"));
assert_eq!(config.model_provider_id, "role-provider");
assert_eq!(config.model_provider.name, "Role Provider");
}
#[tokio::test]
async fn apply_role_uses_role_model_provider_instead_of_current_profile_provider() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.base-provider]
name = "Base Provider"
base_url = "https://base.example.com/v1"
env_key = "BASE_PROVIDER_API_KEY"
wire_api = "responses"
[model_providers.role-provider]
name = "Role Provider"
base_url = "https://role.example.com/v1"
env_key = "ROLE_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.base-profile]
model_provider = "base-provider"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("base-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
let role_path = write_role_config(
&home,
"provider-role.toml",
"developer_instructions = \"Stay focused\"\nmodel_provider = \"role-provider\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.active_profile, None);
assert_eq!(config.model_provider_id, "role-provider");
assert_eq!(config.model_provider.name, "Role Provider");
}
#[tokio::test]
async fn apply_role_uses_active_profile_model_provider_update() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.base-provider]
name = "Base Provider"
base_url = "https://base.example.com/v1"
env_key = "BASE_PROVIDER_API_KEY"
wire_api = "responses"
[model_providers.role-provider]
name = "Role Provider"
base_url = "https://role.example.com/v1"
env_key = "ROLE_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.base-profile]
model_provider = "base-provider"
model_reasoning_effort = "low"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("base-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
let role_path = write_role_config(
&home,
"profile-edit-role.toml",
r#"developer_instructions = "Stay focused"
[profiles.base-profile]
model_provider = "role-provider"
model_reasoning_effort = "high"
"#,
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.active_profile.as_deref(), Some("base-profile"));
assert_eq!(config.model_provider_id, "role-provider");
assert_eq!(config.model_provider.name, "Role Provider");
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
}
#[tokio::test]
#[cfg(not(windows))]
async fn apply_role_does_not_materialize_default_sandbox_workspace_write_fields() {
use codex_protocol::protocol::SandboxPolicy;
let (home, mut config) = test_config_with_cli_overrides(vec![
(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
),
(
"sandbox_workspace_write.network_access".to_string(),
TomlValue::Boolean(true),
),
])
.await;
let role_path = write_role_config(
&home,
"sandbox-role.toml",
r#"developer_instructions = "Stay focused"
[sandbox_workspace_write]
writable_roots = ["./sandbox-root"]
"#,
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
let role_layer = config
.config_layer_stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
.into_iter()
.rfind(|layer| layer.name == ConfigLayerSource::SessionFlags)
.expect("expected a session flags layer");
let sandbox_workspace_write = role_layer
.config
.get("sandbox_workspace_write")
.and_then(TomlValue::as_table)
.expect("role layer should include sandbox_workspace_write");
assert_eq!(
sandbox_workspace_write.contains_key("network_access"),
false
);
assert_eq!(
sandbox_workspace_write.contains_key("exclude_tmpdir_env_var"),
false
);
assert_eq!(
sandbox_workspace_write.contains_key("exclude_slash_tmp"),
false
);
match &*config.permissions.sandbox_policy {
SandboxPolicy::WorkspaceWrite { network_access, .. } => {
assert_eq!(*network_access, true);
}
other => panic!("expected workspace-write sandbox policy, got {other:?}"),
}
}
#[tokio::test]
async fn apply_role_takes_precedence_over_existing_session_flags_for_same_key() {
let (home, mut config) = test_config_with_cli_overrides(vec![(
"model".to_string(),
TomlValue::String("cli-model".to_string()),
)])
.await;
let before_layers = session_flags_layer_count(&config);
let role_path = write_role_config(
&home,
"model-role.toml",
"developer_instructions = \"Stay focused\"\nmodel = \"role-model\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.model.as_deref(), Some("role-model"));
assert_eq!(session_flags_layer_count(&config), before_layers + 1);
}
#[cfg_attr(windows, ignore)]
#[tokio::test]
async fn apply_role_skills_config_disables_skill_for_spawned_agent() {
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let skill_dir = home.path().join("skills").join("demo");
fs::create_dir_all(&skill_dir).expect("create skill dir");
let skill_path = skill_dir.join("SKILL.md");
fs::write(
&skill_path,
"---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n",
)
.expect("write skill");
let role_path = write_role_config(
&home,
"skills-role.toml",
&format!(
r#"developer_instructions = "Stay focused"
[[skills.config]]
path = "{}"
enabled = false
"#,
skill_path.display()
),
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf()));
let skills_manager = SkillsManager::new(home.path().to_path_buf(), plugins_manager, true);
let outcome = skills_manager.skills_for_config(&config);
let skill = outcome
.skills
.iter()
.find(|skill| skill.name == "demo-skill")
.expect("demo skill should be discovered");
assert_eq!(outcome.is_skill_enabled(skill), false);
}
#[test]
fn spawn_tool_spec_build_deduplicates_user_defined_built_in_roles() {
let user_defined_roles = BTreeMap::from([
(
"explorer".to_string(),
AgentRoleConfig {
description: Some("user override".to_string()),
config_file: None,
nickname_candidates: None,
},
),
("researcher".to_string(), AgentRoleConfig::default()),
]);
let spec = spawn_tool_spec::build(&user_defined_roles);
assert!(spec.contains("researcher: no description"));
assert!(spec.contains("explorer: {\nuser override\n}"));
assert!(spec.contains("default: {\nDefault agent.\n}"));
assert!(!spec.contains("Explorers are fast and authoritative."));
}
#[test]
fn spawn_tool_spec_lists_user_defined_roles_before_built_ins() {
let user_defined_roles = BTreeMap::from([(
"aaa".to_string(),
AgentRoleConfig {
description: Some("first".to_string()),
config_file: None,
nickname_candidates: None,
},
)]);
let spec = spawn_tool_spec::build(&user_defined_roles);
let user_index = spec.find("aaa: {\nfirst\n}").expect("find user role");
let built_in_index = spec
.find("default: {\nDefault agent.\n}")
.expect("find built-in role");
assert!(user_index < built_in_index);
}
#[test]
fn spawn_tool_spec_marks_role_locked_model_and_reasoning_effort() {
let tempdir = TempDir::new().expect("create temp dir");
let role_path = tempdir.path().join("researcher.toml");
fs::write(
&role_path,
"developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"\nmodel_reasoning_effort = \"high\"\n",
)
.expect("write role config");
let user_defined_roles = BTreeMap::from([(
"researcher".to_string(),
AgentRoleConfig {
description: Some("Research carefully.".to_string()),
config_file: Some(role_path),
nickname_candidates: None,
},
)]);
let spec = spawn_tool_spec::build(&user_defined_roles);
assert!(spec.contains(
"Research carefully.\n- This role's model is set to `gpt-5` and its reasoning effort is set to `high`. These settings cannot be changed."
));
}
#[test]
fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() {
let tempdir = TempDir::new().expect("create temp dir");
let role_path = tempdir.path().join("reviewer.toml");
fs::write(
&role_path,
"developer_instructions = \"Review carefully\"\nmodel_reasoning_effort = \"medium\"\n",
)
.expect("write role config");
let user_defined_roles = BTreeMap::from([(
"reviewer".to_string(),
AgentRoleConfig {
description: Some("Review carefully.".to_string()),
config_file: Some(role_path),
nickname_candidates: None,
},
)]);
let spec = spawn_tool_spec::build(&user_defined_roles);
assert!(spec.contains(
"Review carefully.\n- This role's reasoning effort is set to `medium` and cannot be changed."
));
}
#[test]
fn built_in_config_file_contents_resolves_explorer_only() {
assert_eq!(
built_in::config_file_contents(Path::new("missing.toml")),
None
);
}
}
#[path = "role_tests.rs"]
mod tests;
+680
View File
@@ -0,0 +1,680 @@
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::plugins::PluginsManager;
use crate::skills::SkillsManager;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
async fn test_config_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
) -> (TempDir, Config) {
let home = TempDir::new().expect("create temp dir");
let home_path = home.path().to_path_buf();
let config = ConfigBuilder::default()
.codex_home(home_path.clone())
.cli_overrides(cli_overrides)
.fallback_cwd(Some(home_path))
.build()
.await
.expect("load test config");
(home, config)
}
async fn write_role_config(home: &TempDir, name: &str, contents: &str) -> PathBuf {
let role_path = home.path().join(name);
tokio::fs::write(&role_path, contents)
.await
.expect("write role config");
role_path
}
fn session_flags_layer_count(config: &Config) -> usize {
config
.config_layer_stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
.into_iter()
.filter(|layer| layer.name == ConfigLayerSource::SessionFlags)
.count()
}
#[tokio::test]
async fn apply_role_defaults_to_default_and_leaves_config_unchanged() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let before = config.clone();
apply_role_to_config(&mut config, None)
.await
.expect("default role should apply");
assert_eq!(before, config);
}
#[tokio::test]
async fn apply_role_returns_error_for_unknown_role() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let err = apply_role_to_config(&mut config, Some("missing-role"))
.await
.expect_err("unknown role should fail");
assert_eq!(err, "unknown agent_type 'missing-role'");
}
#[tokio::test]
#[ignore = "No role requiring it for now"]
async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let before_layers = session_flags_layer_count(&config);
apply_role_to_config(&mut config, Some("explorer"))
.await
.expect("explorer role should apply");
assert_eq!(config.model.as_deref(), Some("gpt-5.1-codex-mini"));
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::Medium));
assert_eq!(session_flags_layer_count(&config), before_layers + 1);
}
#[tokio::test]
async fn apply_role_returns_unavailable_for_missing_user_role_file() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(PathBuf::from("/path/does/not/exist.toml")),
nickname_candidates: None,
},
);
let err = apply_role_to_config(&mut config, Some("custom"))
.await
.expect_err("missing role file should fail");
assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR);
}
#[tokio::test]
async fn apply_role_returns_unavailable_for_invalid_user_role_toml() {
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let role_path = write_role_config(&home, "invalid-role.toml", "model = [").await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
let err = apply_role_to_config(&mut config, Some("custom"))
.await
.expect_err("invalid role file should fail");
assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR);
}
#[tokio::test]
async fn apply_role_ignores_agent_metadata_fields_in_user_role_file() {
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let role_path = write_role_config(
&home,
"metadata-role.toml",
r#"
name = "archivist"
description = "Role metadata"
nickname_candidates = ["Hypatia"]
developer_instructions = "Stay focused"
model = "role-model"
"#,
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.model.as_deref(), Some("role-model"));
}
#[tokio::test]
async fn apply_role_preserves_unspecified_keys() {
let (home, mut config) = test_config_with_cli_overrides(vec![(
"model".to_string(),
TomlValue::String("base-model".to_string()),
)])
.await;
config.codex_linux_sandbox_exe = Some(PathBuf::from("/tmp/codex-linux-sandbox"));
config.main_execve_wrapper_exe = Some(PathBuf::from("/tmp/codex-execve-wrapper"));
let role_path = write_role_config(
&home,
"effort-only.toml",
"developer_instructions = \"Stay focused\"\nmodel_reasoning_effort = \"high\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.model.as_deref(), Some("base-model"));
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(
config.codex_linux_sandbox_exe,
Some(PathBuf::from("/tmp/codex-linux-sandbox"))
);
assert_eq!(
config.main_execve_wrapper_exe,
Some(PathBuf::from("/tmp/codex-execve-wrapper"))
);
}
#[tokio::test]
async fn apply_role_preserves_active_profile_and_model_provider() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.test-provider]
name = "Test Provider"
base_url = "https://example.com/v1"
env_key = "TEST_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.test-profile]
model_provider = "test-provider"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("test-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
let role_path = write_role_config(
&home,
"empty-role.toml",
"developer_instructions = \"Stay focused\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.active_profile.as_deref(), Some("test-profile"));
assert_eq!(config.model_provider_id, "test-provider");
assert_eq!(config.model_provider.name, "Test Provider");
}
#[tokio::test]
async fn apply_role_uses_role_profile_instead_of_current_profile() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.base-provider]
name = "Base Provider"
base_url = "https://base.example.com/v1"
env_key = "BASE_PROVIDER_API_KEY"
wire_api = "responses"
[model_providers.role-provider]
name = "Role Provider"
base_url = "https://role.example.com/v1"
env_key = "ROLE_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.base-profile]
model_provider = "base-provider"
[profiles.role-profile]
model_provider = "role-provider"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("base-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
let role_path = write_role_config(
&home,
"profile-role.toml",
"developer_instructions = \"Stay focused\"\nprofile = \"role-profile\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.active_profile.as_deref(), Some("role-profile"));
assert_eq!(config.model_provider_id, "role-provider");
assert_eq!(config.model_provider.name, "Role Provider");
}
#[tokio::test]
async fn apply_role_uses_role_model_provider_instead_of_current_profile_provider() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.base-provider]
name = "Base Provider"
base_url = "https://base.example.com/v1"
env_key = "BASE_PROVIDER_API_KEY"
wire_api = "responses"
[model_providers.role-provider]
name = "Role Provider"
base_url = "https://role.example.com/v1"
env_key = "ROLE_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.base-profile]
model_provider = "base-provider"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("base-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
let role_path = write_role_config(
&home,
"provider-role.toml",
"developer_instructions = \"Stay focused\"\nmodel_provider = \"role-provider\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.active_profile, None);
assert_eq!(config.model_provider_id, "role-provider");
assert_eq!(config.model_provider.name, "Role Provider");
}
#[tokio::test]
async fn apply_role_uses_active_profile_model_provider_update() {
let home = TempDir::new().expect("create temp dir");
tokio::fs::write(
home.path().join(CONFIG_TOML_FILE),
r#"
[model_providers.base-provider]
name = "Base Provider"
base_url = "https://base.example.com/v1"
env_key = "BASE_PROVIDER_API_KEY"
wire_api = "responses"
[model_providers.role-provider]
name = "Role Provider"
base_url = "https://role.example.com/v1"
env_key = "ROLE_PROVIDER_API_KEY"
wire_api = "responses"
[profiles.base-profile]
model_provider = "base-provider"
model_reasoning_effort = "low"
"#,
)
.await
.expect("write config.toml");
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
config_profile: Some("base-profile".to_string()),
..Default::default()
})
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await
.expect("load config");
let role_path = write_role_config(
&home,
"profile-edit-role.toml",
r#"developer_instructions = "Stay focused"
[profiles.base-profile]
model_provider = "role-provider"
model_reasoning_effort = "high"
"#,
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.active_profile.as_deref(), Some("base-profile"));
assert_eq!(config.model_provider_id, "role-provider");
assert_eq!(config.model_provider.name, "Role Provider");
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
}
#[tokio::test]
#[cfg(not(windows))]
async fn apply_role_does_not_materialize_default_sandbox_workspace_write_fields() {
use codex_protocol::protocol::SandboxPolicy;
let (home, mut config) = test_config_with_cli_overrides(vec![
(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
),
(
"sandbox_workspace_write.network_access".to_string(),
TomlValue::Boolean(true),
),
])
.await;
let role_path = write_role_config(
&home,
"sandbox-role.toml",
r#"developer_instructions = "Stay focused"
[sandbox_workspace_write]
writable_roots = ["./sandbox-root"]
"#,
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
let role_layer = config
.config_layer_stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
.into_iter()
.rfind(|layer| layer.name == ConfigLayerSource::SessionFlags)
.expect("expected a session flags layer");
let sandbox_workspace_write = role_layer
.config
.get("sandbox_workspace_write")
.and_then(TomlValue::as_table)
.expect("role layer should include sandbox_workspace_write");
assert_eq!(
sandbox_workspace_write.contains_key("network_access"),
false
);
assert_eq!(
sandbox_workspace_write.contains_key("exclude_tmpdir_env_var"),
false
);
assert_eq!(
sandbox_workspace_write.contains_key("exclude_slash_tmp"),
false
);
match &*config.permissions.sandbox_policy {
SandboxPolicy::WorkspaceWrite { network_access, .. } => {
assert_eq!(*network_access, true);
}
other => panic!("expected workspace-write sandbox policy, got {other:?}"),
}
}
#[tokio::test]
async fn apply_role_takes_precedence_over_existing_session_flags_for_same_key() {
let (home, mut config) = test_config_with_cli_overrides(vec![(
"model".to_string(),
TomlValue::String("cli-model".to_string()),
)])
.await;
let before_layers = session_flags_layer_count(&config);
let role_path = write_role_config(
&home,
"model-role.toml",
"developer_instructions = \"Stay focused\"\nmodel = \"role-model\"",
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
assert_eq!(config.model.as_deref(), Some("role-model"));
assert_eq!(session_flags_layer_count(&config), before_layers + 1);
}
#[cfg_attr(windows, ignore)]
#[tokio::test]
async fn apply_role_skills_config_disables_skill_for_spawned_agent() {
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let skill_dir = home.path().join("skills").join("demo");
fs::create_dir_all(&skill_dir).expect("create skill dir");
let skill_path = skill_dir.join("SKILL.md");
fs::write(
&skill_path,
"---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n",
)
.expect("write skill");
let role_path = write_role_config(
&home,
"skills-role.toml",
&format!(
r#"developer_instructions = "Stay focused"
[[skills.config]]
path = "{}"
enabled = false
"#,
skill_path.display()
),
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);
apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");
let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf()));
let skills_manager = SkillsManager::new(home.path().to_path_buf(), plugins_manager, true);
let outcome = skills_manager.skills_for_config(&config);
let skill = outcome
.skills
.iter()
.find(|skill| skill.name == "demo-skill")
.expect("demo skill should be discovered");
assert_eq!(outcome.is_skill_enabled(skill), false);
}
#[test]
fn spawn_tool_spec_build_deduplicates_user_defined_built_in_roles() {
let user_defined_roles = BTreeMap::from([
(
"explorer".to_string(),
AgentRoleConfig {
description: Some("user override".to_string()),
config_file: None,
nickname_candidates: None,
},
),
("researcher".to_string(), AgentRoleConfig::default()),
]);
let spec = spawn_tool_spec::build(&user_defined_roles);
assert!(spec.contains("researcher: no description"));
assert!(spec.contains("explorer: {\nuser override\n}"));
assert!(spec.contains("default: {\nDefault agent.\n}"));
assert!(!spec.contains("Explorers are fast and authoritative."));
}
#[test]
fn spawn_tool_spec_lists_user_defined_roles_before_built_ins() {
let user_defined_roles = BTreeMap::from([(
"aaa".to_string(),
AgentRoleConfig {
description: Some("first".to_string()),
config_file: None,
nickname_candidates: None,
},
)]);
let spec = spawn_tool_spec::build(&user_defined_roles);
let user_index = spec.find("aaa: {\nfirst\n}").expect("find user role");
let built_in_index = spec
.find("default: {\nDefault agent.\n}")
.expect("find built-in role");
assert!(user_index < built_in_index);
}
#[test]
fn spawn_tool_spec_marks_role_locked_model_and_reasoning_effort() {
let tempdir = TempDir::new().expect("create temp dir");
let role_path = tempdir.path().join("researcher.toml");
fs::write(
&role_path,
"developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"\nmodel_reasoning_effort = \"high\"\n",
)
.expect("write role config");
let user_defined_roles = BTreeMap::from([(
"researcher".to_string(),
AgentRoleConfig {
description: Some("Research carefully.".to_string()),
config_file: Some(role_path),
nickname_candidates: None,
},
)]);
let spec = spawn_tool_spec::build(&user_defined_roles);
assert!(spec.contains(
"Research carefully.\n- This role's model is set to `gpt-5` and its reasoning effort is set to `high`. These settings cannot be changed."
));
}
#[test]
fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() {
let tempdir = TempDir::new().expect("create temp dir");
let role_path = tempdir.path().join("reviewer.toml");
fs::write(
&role_path,
"developer_instructions = \"Review carefully\"\nmodel_reasoning_effort = \"medium\"\n",
)
.expect("write role config");
let user_defined_roles = BTreeMap::from([(
"reviewer".to_string(),
AgentRoleConfig {
description: Some("Review carefully.".to_string()),
config_file: Some(role_path),
nickname_candidates: None,
},
)]);
let spec = spawn_tool_spec::build(&user_defined_roles);
assert!(spec.contains(
"Review carefully.\n- This role's reasoning effort is set to `medium` and cannot be changed."
));
}
#[test]
fn built_in_config_file_contents_resolves_explorer_only() {
assert_eq!(
built_in::config_file_contents(Path::new("missing.toml")),
None
);
}
+2 -179
View File
@@ -489,182 +489,5 @@ fn normalize_path_for_skill_id(
}
#[cfg(test)]
mod tests {
use super::AnalyticsEventsQueue;
use super::AppInvocation;
use super::CodexAppMentionedEventRequest;
use super::CodexAppUsedEventRequest;
use super::InvocationType;
use super::TrackEventRequest;
use super::TrackEventsContext;
use super::codex_app_metadata;
use super::normalize_path_for_skill_id;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use tokio::sync::mpsc;
fn expected_absolute_path(path: &PathBuf) -> String {
std::fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.replace('\\', "/")
}
#[test]
fn normalize_path_for_skill_id_repo_scoped_uses_relative_path() {
let repo_root = PathBuf::from("/repo/root");
let skill_path = PathBuf::from("/repo/root/.codex/skills/doc/SKILL.md");
let path = normalize_path_for_skill_id(
Some("https://example.com/repo.git"),
Some(repo_root.as_path()),
skill_path.as_path(),
);
assert_eq!(path, ".codex/skills/doc/SKILL.md");
}
#[test]
fn normalize_path_for_skill_id_user_scoped_uses_absolute_path() {
let skill_path = PathBuf::from("/Users/abc/.codex/skills/doc/SKILL.md");
let path = normalize_path_for_skill_id(None, None, skill_path.as_path());
let expected = expected_absolute_path(&skill_path);
assert_eq!(path, expected);
}
#[test]
fn normalize_path_for_skill_id_admin_scoped_uses_absolute_path() {
let skill_path = PathBuf::from("/etc/codex/skills/doc/SKILL.md");
let path = normalize_path_for_skill_id(None, None, skill_path.as_path());
let expected = expected_absolute_path(&skill_path);
assert_eq!(path, expected);
}
#[test]
fn normalize_path_for_skill_id_repo_root_not_in_skill_path_uses_absolute_path() {
let repo_root = PathBuf::from("/repo/root");
let skill_path = PathBuf::from("/other/path/.codex/skills/doc/SKILL.md");
let path = normalize_path_for_skill_id(
Some("https://example.com/repo.git"),
Some(repo_root.as_path()),
skill_path.as_path(),
);
let expected = expected_absolute_path(&skill_path);
assert_eq!(path, expected);
}
#[test]
fn app_mentioned_event_serializes_expected_shape() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
};
let event = TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest {
event_type: "codex_app_mentioned",
event_params: codex_app_metadata(
&tracking,
AppInvocation {
connector_id: Some("calendar".to_string()),
app_name: Some("Calendar".to_string()),
invocation_type: Some(InvocationType::Explicit),
},
),
});
let payload = serde_json::to_value(&event).expect("serialize app mentioned event");
assert_eq!(
payload,
json!({
"event_type": "codex_app_mentioned",
"event_params": {
"connector_id": "calendar",
"thread_id": "thread-1",
"turn_id": "turn-1",
"app_name": "Calendar",
"product_client_id": crate::default_client::originator().value,
"invoke_type": "explicit",
"model_slug": "gpt-5"
}
})
);
}
#[test]
fn app_used_event_serializes_expected_shape() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
};
let event = TrackEventRequest::AppUsed(CodexAppUsedEventRequest {
event_type: "codex_app_used",
event_params: codex_app_metadata(
&tracking,
AppInvocation {
connector_id: Some("drive".to_string()),
app_name: Some("Google Drive".to_string()),
invocation_type: Some(InvocationType::Implicit),
},
),
});
let payload = serde_json::to_value(&event).expect("serialize app used event");
assert_eq!(
payload,
json!({
"event_type": "codex_app_used",
"event_params": {
"connector_id": "drive",
"thread_id": "thread-2",
"turn_id": "turn-2",
"app_name": "Google Drive",
"product_client_id": crate::default_client::originator().value,
"invoke_type": "implicit",
"model_slug": "gpt-5"
}
})
);
}
#[test]
fn app_used_dedupe_is_keyed_by_turn_and_connector() {
let (sender, _receiver) = mpsc::channel(1);
let queue = AnalyticsEventsQueue {
sender,
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
};
let app = AppInvocation {
connector_id: Some("calendar".to_string()),
app_name: Some("Calendar".to_string()),
invocation_type: Some(InvocationType::Implicit),
};
let turn_1 = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
};
let turn_2 = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-2".to_string(),
};
assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), true);
assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), false);
assert_eq!(queue.should_enqueue_app_used(&turn_2, &app), true);
}
}
#[path = "analytics_client_tests.rs"]
mod tests;
+177
View File
@@ -0,0 +1,177 @@
use super::AnalyticsEventsQueue;
use super::AppInvocation;
use super::CodexAppMentionedEventRequest;
use super::CodexAppUsedEventRequest;
use super::InvocationType;
use super::TrackEventRequest;
use super::TrackEventsContext;
use super::codex_app_metadata;
use super::normalize_path_for_skill_id;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use tokio::sync::mpsc;
fn expected_absolute_path(path: &PathBuf) -> String {
std::fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.replace('\\', "/")
}
#[test]
fn normalize_path_for_skill_id_repo_scoped_uses_relative_path() {
let repo_root = PathBuf::from("/repo/root");
let skill_path = PathBuf::from("/repo/root/.codex/skills/doc/SKILL.md");
let path = normalize_path_for_skill_id(
Some("https://example.com/repo.git"),
Some(repo_root.as_path()),
skill_path.as_path(),
);
assert_eq!(path, ".codex/skills/doc/SKILL.md");
}
#[test]
fn normalize_path_for_skill_id_user_scoped_uses_absolute_path() {
let skill_path = PathBuf::from("/Users/abc/.codex/skills/doc/SKILL.md");
let path = normalize_path_for_skill_id(None, None, skill_path.as_path());
let expected = expected_absolute_path(&skill_path);
assert_eq!(path, expected);
}
#[test]
fn normalize_path_for_skill_id_admin_scoped_uses_absolute_path() {
let skill_path = PathBuf::from("/etc/codex/skills/doc/SKILL.md");
let path = normalize_path_for_skill_id(None, None, skill_path.as_path());
let expected = expected_absolute_path(&skill_path);
assert_eq!(path, expected);
}
#[test]
fn normalize_path_for_skill_id_repo_root_not_in_skill_path_uses_absolute_path() {
let repo_root = PathBuf::from("/repo/root");
let skill_path = PathBuf::from("/other/path/.codex/skills/doc/SKILL.md");
let path = normalize_path_for_skill_id(
Some("https://example.com/repo.git"),
Some(repo_root.as_path()),
skill_path.as_path(),
);
let expected = expected_absolute_path(&skill_path);
assert_eq!(path, expected);
}
#[test]
fn app_mentioned_event_serializes_expected_shape() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
};
let event = TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest {
event_type: "codex_app_mentioned",
event_params: codex_app_metadata(
&tracking,
AppInvocation {
connector_id: Some("calendar".to_string()),
app_name: Some("Calendar".to_string()),
invocation_type: Some(InvocationType::Explicit),
},
),
});
let payload = serde_json::to_value(&event).expect("serialize app mentioned event");
assert_eq!(
payload,
json!({
"event_type": "codex_app_mentioned",
"event_params": {
"connector_id": "calendar",
"thread_id": "thread-1",
"turn_id": "turn-1",
"app_name": "Calendar",
"product_client_id": crate::default_client::originator().value,
"invoke_type": "explicit",
"model_slug": "gpt-5"
}
})
);
}
#[test]
fn app_used_event_serializes_expected_shape() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
};
let event = TrackEventRequest::AppUsed(CodexAppUsedEventRequest {
event_type: "codex_app_used",
event_params: codex_app_metadata(
&tracking,
AppInvocation {
connector_id: Some("drive".to_string()),
app_name: Some("Google Drive".to_string()),
invocation_type: Some(InvocationType::Implicit),
},
),
});
let payload = serde_json::to_value(&event).expect("serialize app used event");
assert_eq!(
payload,
json!({
"event_type": "codex_app_used",
"event_params": {
"connector_id": "drive",
"thread_id": "thread-2",
"turn_id": "turn-2",
"app_name": "Google Drive",
"product_client_id": crate::default_client::originator().value,
"invoke_type": "implicit",
"model_slug": "gpt-5"
}
})
);
}
#[test]
fn app_used_dedupe_is_keyed_by_turn_and_connector() {
let (sender, _receiver) = mpsc::channel(1);
let queue = AnalyticsEventsQueue {
sender,
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
};
let app = AppInvocation {
connector_id: Some("calendar".to_string()),
app_name: Some("Calendar".to_string()),
invocation_type: Some(InvocationType::Implicit),
};
let turn_1 = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
};
let turn_2 = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-2".to_string(),
};
assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), true);
assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), false);
assert_eq!(queue.should_enqueue_app_used(&turn_2, &app), true);
}
+2 -98
View File
@@ -120,104 +120,8 @@ const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id";
const CF_RAY_HEADER: &str = "cf-ray";
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn map_api_error_maps_server_overloaded() {
let err = map_api_error(ApiError::ServerOverloaded);
assert!(matches!(err, CodexErr::ServerOverloaded));
}
#[test]
fn map_api_error_maps_server_overloaded_from_503_body() {
let body = serde_json::json!({
"error": {
"code": "server_is_overloaded"
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::SERVICE_UNAVAILABLE,
url: Some("http://example.com/v1/responses".to_string()),
headers: None,
body: Some(body),
}));
assert!(matches!(err, CodexErr::ServerOverloaded));
}
#[test]
fn map_api_error_maps_usage_limit_limit_name_header() {
let mut headers = HeaderMap::new();
headers.insert(
ACTIVE_LIMIT_HEADER,
http::HeaderValue::from_static("codex_other"),
);
headers.insert(
"x-codex-other-limit-name",
http::HeaderValue::from_static("codex_other"),
);
let body = serde_json::json!({
"error": {
"type": "usage_limit_reached",
"plan_type": "pro",
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::TOO_MANY_REQUESTS,
url: Some("http://example.com/v1/responses".to_string()),
headers: Some(headers),
body: Some(body),
}));
let CodexErr::UsageLimitReached(usage_limit) = err else {
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
};
assert_eq!(
usage_limit
.rate_limits
.as_ref()
.and_then(|snapshot| snapshot.limit_name.as_deref()),
Some("codex_other")
);
}
#[test]
fn map_api_error_does_not_fallback_limit_name_to_limit_id() {
let mut headers = HeaderMap::new();
headers.insert(
ACTIVE_LIMIT_HEADER,
http::HeaderValue::from_static("codex_other"),
);
let body = serde_json::json!({
"error": {
"type": "usage_limit_reached",
"plan_type": "pro",
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::TOO_MANY_REQUESTS,
url: Some("http://example.com/v1/responses".to_string()),
headers: Some(headers),
body: Some(body),
}));
let CodexErr::UsageLimitReached(usage_limit) = err else {
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
};
assert_eq!(
usage_limit
.rate_limits
.as_ref()
.and_then(|snapshot| snapshot.limit_name.as_deref()),
None
);
}
}
#[path = "api_bridge_tests.rs"]
mod tests;
fn extract_request_tracking_id(headers: Option<&HeaderMap>) -> Option<String> {
extract_request_id(headers).or_else(|| extract_header(headers, CF_RAY_HEADER))
+96
View File
@@ -0,0 +1,96 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn map_api_error_maps_server_overloaded() {
let err = map_api_error(ApiError::ServerOverloaded);
assert!(matches!(err, CodexErr::ServerOverloaded));
}
#[test]
fn map_api_error_maps_server_overloaded_from_503_body() {
let body = serde_json::json!({
"error": {
"code": "server_is_overloaded"
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::SERVICE_UNAVAILABLE,
url: Some("http://example.com/v1/responses".to_string()),
headers: None,
body: Some(body),
}));
assert!(matches!(err, CodexErr::ServerOverloaded));
}
#[test]
fn map_api_error_maps_usage_limit_limit_name_header() {
let mut headers = HeaderMap::new();
headers.insert(
ACTIVE_LIMIT_HEADER,
http::HeaderValue::from_static("codex_other"),
);
headers.insert(
"x-codex-other-limit-name",
http::HeaderValue::from_static("codex_other"),
);
let body = serde_json::json!({
"error": {
"type": "usage_limit_reached",
"plan_type": "pro",
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::TOO_MANY_REQUESTS,
url: Some("http://example.com/v1/responses".to_string()),
headers: Some(headers),
body: Some(body),
}));
let CodexErr::UsageLimitReached(usage_limit) = err else {
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
};
assert_eq!(
usage_limit
.rate_limits
.as_ref()
.and_then(|snapshot| snapshot.limit_name.as_deref()),
Some("codex_other")
);
}
#[test]
fn map_api_error_does_not_fallback_limit_name_to_limit_id() {
let mut headers = HeaderMap::new();
headers.insert(
ACTIVE_LIMIT_HEADER,
http::HeaderValue::from_static("codex_other"),
);
let body = serde_json::json!({
"error": {
"type": "usage_limit_reached",
"plan_type": "pro",
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::TOO_MANY_REQUESTS,
url: Some("http://example.com/v1/responses".to_string()),
headers: Some(headers),
body: Some(body),
}));
let CodexErr::UsageLimitReached(usage_limit) = err else {
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
};
assert_eq!(
usage_limit
.rate_limits
.as_ref()
.and_then(|snapshot| snapshot.limit_name.as_deref()),
None
);
}
+5 -24
View File
@@ -1,6 +1,7 @@
use crate::codex::TurnContext;
use crate::function_tool::FunctionCallError;
use crate::protocol::FileChange;
use crate::protocol::FileSystemSandboxPolicy;
use crate::safety::SafetyCheck;
use crate::safety::assess_patch_safety;
use crate::tools::sandboxing::ExecApprovalRequirement;
@@ -34,13 +35,14 @@ pub(crate) struct ApplyPatchExec {
pub(crate) async fn apply_patch(
turn_context: &TurnContext,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
action: ApplyPatchAction,
) -> InternalApplyPatchInvocation {
match assess_patch_safety(
&action,
turn_context.approval_policy.value(),
turn_context.sandbox_policy.get(),
&turn_context.file_system_sandbox_policy,
file_system_sandbox_policy,
&turn_context.cwd,
turn_context.windows_sandbox_level,
) {
@@ -102,26 +104,5 @@ pub(crate) fn convert_apply_patch_to_protocol(
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn convert_apply_patch_maps_add_variant() {
let tmp = tempdir().expect("tmp");
let p = tmp.path().join("a.txt");
// Create an action with a single Add change
let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string());
let got = convert_apply_patch_to_protocol(&action);
assert_eq!(
got.get(&p),
Some(&FileChange::Add {
content: "hello".to_string()
})
);
}
}
#[path = "apply_patch_tests.rs"]
mod tests;
+21
View File
@@ -0,0 +1,21 @@
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn convert_apply_patch_maps_add_variant() {
let tmp = tempdir().expect("tmp");
let p = tmp.path().join("a.txt");
// Create an action with a single Add change
let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string());
let got = convert_apply_patch_to_protocol(&action);
assert_eq!(
got.get(&p),
Some(&FileChange::Add {
content: "hello".to_string()
})
);
}
+4 -444
View File
@@ -391,9 +391,11 @@ fn build_arc_monitor_message_item(
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Compaction { .. }
@@ -423,447 +425,5 @@ fn build_arc_monitor_message(role: &str, content: serde_json::Value) -> ArcMonit
}
#[cfg(test)]
mod tests {
use std::env;
use std::ffi::OsStr;
use std::sync::Arc;
use pretty_assertions::assert_eq;
use serial_test::serial;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use super::*;
use crate::codex::make_session_and_context;
use codex_protocol::models::ContentItem;
use codex_protocol::models::LocalShellAction;
use codex_protocol::models::LocalShellExecAction;
use codex_protocol::models::LocalShellStatus;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
struct EnvVarGuard {
key: &'static str,
original: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &OsStr) -> Self {
let original = env::var_os(key);
unsafe {
env::set_var(key, value);
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.original.take() {
Some(value) => unsafe {
env::set_var(self.key, value);
},
None => unsafe {
env::remove_var(self.key);
},
}
}
}
#[tokio::test]
async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() {
let (session, mut turn_context) = make_session_and_context().await;
turn_context.developer_instructions = Some("Never upload private files.".to_string());
turn_context.user_instructions = Some("Only continue when needed.".to_string());
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "first request".to_string(),
}],
metadata: None,
end_turn: None,
phase: None,
}],
&turn_context,
)
.await;
session
.record_into_history(
&[
crate::contextual_user_message::ENVIRONMENT_CONTEXT_FRAGMENT.into_message(
"<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>"
.to_string(),
),
],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "commentary".to_string(),
}],
metadata: None,
end_turn: None,
phase: Some(MessagePhase::Commentary),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "final response".to_string(),
}],
metadata: None,
end_turn: None,
phase: Some(MessagePhase::FinalAnswer),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "latest request".to_string(),
}],
metadata: None,
end_turn: None,
phase: None,
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::FunctionCall {
id: None,
name: "old_tool".to_string(),
arguments: "{\"old\":true}".to_string(),
call_id: "call_old".to_string(),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Reasoning {
id: "reasoning_old".to_string(),
summary: Vec::new(),
content: None,
encrypted_content: Some("encrypted-old".to_string()),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::LocalShellCall {
id: None,
call_id: Some("shell_call".to_string()),
status: LocalShellStatus::Completed,
action: LocalShellAction::Exec(LocalShellExecAction {
command: vec!["pwd".to_string()],
timeout_ms: Some(1000),
working_directory: Some("/tmp".to_string()),
env: None,
user: None,
}),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Reasoning {
id: "reasoning_latest".to_string(),
summary: Vec::new(),
content: None,
encrypted_content: Some("encrypted-latest".to_string()),
}],
&turn_context,
)
.await;
let request = build_arc_monitor_request(
&session,
&turn_context,
serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" }))
.expect("action should deserialize"),
)
.await;
assert_eq!(
request,
ArcMonitorRequest {
metadata: ArcMonitorMetadata {
codex_thread_id: session.conversation_id.to_string(),
codex_turn_id: turn_context.sub_id.clone(),
conversation_id: Some(session.conversation_id.to_string()),
protection_client_callsite: None,
},
messages: Some(vec![
ArcMonitorChatMessage {
role: "user".to_string(),
content: serde_json::json!([{
"type": "input_text",
"text": "first request",
}]),
},
ArcMonitorChatMessage {
role: "assistant".to_string(),
content: serde_json::json!([{
"type": "output_text",
"text": "final response",
}]),
},
ArcMonitorChatMessage {
role: "user".to_string(),
content: serde_json::json!([{
"type": "input_text",
"text": "latest request",
}]),
},
ArcMonitorChatMessage {
role: "assistant".to_string(),
content: serde_json::json!([{
"type": "tool_call",
"tool_name": "shell",
"action": {
"type": "exec",
"command": ["pwd"],
"timeout_ms": 1000,
"working_directory": "/tmp",
"env": null,
"user": null,
},
}]),
},
ArcMonitorChatMessage {
role: "assistant".to_string(),
content: serde_json::json!([{
"type": "encrypted_reasoning",
"encrypted_content": "encrypted-latest",
}]),
},
]),
input: None,
policies: Some(ArcMonitorPolicies {
user: None,
developer: None,
}),
action: serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" }))
.expect("action should deserialize"),
}
);
}
#[tokio::test]
#[serial(arc_monitor_env)]
async fn monitor_action_posts_expected_arc_request() {
let server = MockServer::start().await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
turn_context.developer_instructions = Some("Developer policy".to_string());
turn_context.user_instructions = Some("User policy".to_string());
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
metadata: None,
end_turn: None,
phase: None,
}],
&turn_context,
)
.await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(serde_json::json!({
"metadata": {
"codex_thread_id": session.conversation_id.to_string(),
"codex_turn_id": turn_context.sub_id.clone(),
"conversation_id": session.conversation_id.to_string(),
},
"messages": [{
"role": "user",
"content": [{
"type": "input_text",
"text": "please run the tool",
}],
}],
"policies": {
"developer": null,
"user": null,
},
"action": {
"tool": "mcp_tool_call",
},
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "ask-user",
"short_reason": "needs confirmation",
"rationale": "tool call needs additional review",
"risk_score": 42,
"risk_level": "medium",
"evidence": [{
"message": "browser_navigate",
"why": "tool call needs additional review",
}],
})))
.expect(1)
.mount(&server)
.await;
let outcome = monitor_action(
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
)
.await;
assert_eq!(
outcome,
ArcMonitorOutcome::AskUser("needs confirmation".to_string())
);
}
#[tokio::test]
#[serial(arc_monitor_env)]
async fn monitor_action_uses_env_url_and_token_overrides() {
let server = MockServer::start().await;
let _url_guard = EnvVarGuard::set(
CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE,
OsStr::new(&format!("{}/override/arc", server.uri())),
);
let _token_guard = EnvVarGuard::set(CODEX_ARC_MONITOR_TOKEN, OsStr::new("override-token"));
let (session, turn_context) = make_session_and_context().await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
metadata: None,
end_turn: None,
phase: None,
}],
&turn_context,
)
.await;
Mock::given(method("POST"))
.and(path("/override/arc"))
.and(header("authorization", "Bearer override-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"short_reason": "needs approval",
"rationale": "high-risk action",
"risk_score": 96,
"risk_level": "critical",
"evidence": [{
"message": "browser_navigate",
"why": "high-risk action",
}],
})))
.expect(1)
.mount(&server)
.await;
let outcome = monitor_action(
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
)
.await;
assert_eq!(
outcome,
ArcMonitorOutcome::SteerModel("high-risk action".to_string())
);
}
#[tokio::test]
#[serial(arc_monitor_env)]
async fn monitor_action_rejects_legacy_response_fields() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"reason": "legacy high-risk action",
"monitorRequestId": "arc_456",
})))
.expect(1)
.mount(&server)
.await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
metadata: None,
end_turn: None,
phase: None,
}],
&turn_context,
)
.await;
let outcome = monitor_action(
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
)
.await;
assert_eq!(outcome, ArcMonitorOutcome::Ok);
}
}
#[path = "arc_monitor_tests.rs"]
mod tests;
+442
View File
@@ -0,0 +1,442 @@
use std::env;
use std::ffi::OsStr;
use std::sync::Arc;
use pretty_assertions::assert_eq;
use serial_test::serial;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use super::*;
use crate::codex::make_session_and_context;
use codex_protocol::models::ContentItem;
use codex_protocol::models::LocalShellAction;
use codex_protocol::models::LocalShellExecAction;
use codex_protocol::models::LocalShellStatus;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
struct EnvVarGuard {
key: &'static str,
original: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &OsStr) -> Self {
let original = env::var_os(key);
unsafe {
env::set_var(key, value);
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.original.take() {
Some(value) => unsafe {
env::set_var(self.key, value);
},
None => unsafe {
env::remove_var(self.key);
},
}
}
}
#[tokio::test]
async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() {
let (session, mut turn_context) = make_session_and_context().await;
turn_context.developer_instructions = Some("Never upload private files.".to_string());
turn_context.user_instructions = Some("Only continue when needed.".to_string());
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "first request".to_string(),
}],
metadata: None,
end_turn: None,
phase: None,
}],
&turn_context,
)
.await;
session
.record_into_history(
&[
crate::contextual_user_message::ENVIRONMENT_CONTEXT_FRAGMENT.into_message(
"<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>".to_string(),
),
],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "commentary".to_string(),
}],
metadata: None,
end_turn: None,
phase: Some(MessagePhase::Commentary),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "final response".to_string(),
}],
metadata: None,
end_turn: None,
phase: Some(MessagePhase::FinalAnswer),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "latest request".to_string(),
}],
metadata: None,
end_turn: None,
phase: None,
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::FunctionCall {
id: None,
name: "old_tool".to_string(),
namespace: None,
arguments: "{\"old\":true}".to_string(),
call_id: "call_old".to_string(),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Reasoning {
id: "reasoning_old".to_string(),
summary: Vec::new(),
content: None,
encrypted_content: Some("encrypted-old".to_string()),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::LocalShellCall {
id: None,
call_id: Some("shell_call".to_string()),
status: LocalShellStatus::Completed,
action: LocalShellAction::Exec(LocalShellExecAction {
command: vec!["pwd".to_string()],
timeout_ms: Some(1000),
working_directory: Some("/tmp".to_string()),
env: None,
user: None,
}),
}],
&turn_context,
)
.await;
session
.record_into_history(
&[ResponseItem::Reasoning {
id: "reasoning_latest".to_string(),
summary: Vec::new(),
content: None,
encrypted_content: Some("encrypted-latest".to_string()),
}],
&turn_context,
)
.await;
let request = build_arc_monitor_request(
&session,
&turn_context,
serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" }))
.expect("action should deserialize"),
)
.await;
assert_eq!(
request,
ArcMonitorRequest {
metadata: ArcMonitorMetadata {
codex_thread_id: session.conversation_id.to_string(),
codex_turn_id: turn_context.sub_id.clone(),
conversation_id: Some(session.conversation_id.to_string()),
protection_client_callsite: None,
},
messages: Some(vec![
ArcMonitorChatMessage {
role: "user".to_string(),
content: serde_json::json!([{
"type": "input_text",
"text": "first request",
}]),
},
ArcMonitorChatMessage {
role: "assistant".to_string(),
content: serde_json::json!([{
"type": "output_text",
"text": "final response",
}]),
},
ArcMonitorChatMessage {
role: "user".to_string(),
content: serde_json::json!([{
"type": "input_text",
"text": "latest request",
}]),
},
ArcMonitorChatMessage {
role: "assistant".to_string(),
content: serde_json::json!([{
"type": "tool_call",
"tool_name": "shell",
"action": {
"type": "exec",
"command": ["pwd"],
"timeout_ms": 1000,
"working_directory": "/tmp",
"env": null,
"user": null,
},
}]),
},
ArcMonitorChatMessage {
role: "assistant".to_string(),
content: serde_json::json!([{
"type": "encrypted_reasoning",
"encrypted_content": "encrypted-latest",
}]),
},
]),
input: None,
policies: Some(ArcMonitorPolicies {
user: None,
developer: None,
}),
action: serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" }))
.expect("action should deserialize"),
}
);
}
#[tokio::test]
#[serial(arc_monitor_env)]
async fn monitor_action_posts_expected_arc_request() {
let server = MockServer::start().await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
turn_context.developer_instructions = Some("Developer policy".to_string());
turn_context.user_instructions = Some("User policy".to_string());
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
metadata: None,
end_turn: None,
phase: None,
}],
&turn_context,
)
.await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(serde_json::json!({
"metadata": {
"codex_thread_id": session.conversation_id.to_string(),
"codex_turn_id": turn_context.sub_id.clone(),
"conversation_id": session.conversation_id.to_string(),
},
"messages": [{
"role": "user",
"content": [{
"type": "input_text",
"text": "please run the tool",
}],
}],
"policies": {
"developer": null,
"user": null,
},
"action": {
"tool": "mcp_tool_call",
},
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "ask-user",
"short_reason": "needs confirmation",
"rationale": "tool call needs additional review",
"risk_score": 42,
"risk_level": "medium",
"evidence": [{
"message": "browser_navigate",
"why": "tool call needs additional review",
}],
})))
.expect(1)
.mount(&server)
.await;
let outcome = monitor_action(
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
)
.await;
assert_eq!(
outcome,
ArcMonitorOutcome::AskUser("needs confirmation".to_string())
);
}
#[tokio::test]
#[serial(arc_monitor_env)]
async fn monitor_action_uses_env_url_and_token_overrides() {
let server = MockServer::start().await;
let _url_guard = EnvVarGuard::set(
CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE,
OsStr::new(&format!("{}/override/arc", server.uri())),
);
let _token_guard = EnvVarGuard::set(CODEX_ARC_MONITOR_TOKEN, OsStr::new("override-token"));
let (session, turn_context) = make_session_and_context().await;
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
metadata: None,
end_turn: None,
phase: None,
}],
&turn_context,
)
.await;
Mock::given(method("POST"))
.and(path("/override/arc"))
.and(header("authorization", "Bearer override-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"short_reason": "needs approval",
"rationale": "high-risk action",
"risk_score": 96,
"risk_level": "critical",
"evidence": [{
"message": "browser_navigate",
"why": "high-risk action",
}],
})))
.expect(1)
.mount(&server)
.await;
let outcome = monitor_action(
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
)
.await;
assert_eq!(
outcome,
ArcMonitorOutcome::SteerModel("high-risk action".to_string())
);
}
#[tokio::test]
#[serial(arc_monitor_env)]
async fn monitor_action_rejects_legacy_response_fields() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"reason": "legacy high-risk action",
"monitorRequestId": "arc_456",
})))
.expect(1)
.mount(&server)
.await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
metadata: None,
end_turn: None,
phase: None,
}],
&turn_context,
)
.await;
let outcome = monitor_action(
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
)
.await;
assert_eq!(outcome, ArcMonitorOutcome::Ok);
}
+2 -434
View File
@@ -1366,437 +1366,5 @@ impl AuthManager {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::storage::FileAuthStorage;
use crate::auth::storage::get_auth_file;
use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::token_data::IdTokenInfo;
use crate::token_data::KnownPlan as InternalKnownPlan;
use crate::token_data::PlanType as InternalPlanType;
use codex_protocol::account::PlanType as AccountPlanType;
use base64::Engine;
use codex_protocol::config_types::ForcedLoginMethod;
use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn refresh_without_id_token() {
let codex_home = tempdir().unwrap();
let fake_jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let storage = create_auth_storage(
codex_home.path().to_path_buf(),
AuthCredentialsStoreMode::File,
);
let updated = super::persist_tokens(
&storage,
None,
Some("new-access-token".to_string()),
Some("new-refresh-token".to_string()),
)
.expect("update_tokens should succeed");
let tokens = updated.tokens.expect("tokens should exist");
assert_eq!(tokens.id_token.raw_jwt, fake_jwt);
assert_eq!(tokens.access_token, "new-access-token");
assert_eq!(tokens.refresh_token, "new-refresh-token");
}
#[test]
fn login_with_api_key_overwrites_existing_auth_json() {
let dir = tempdir().unwrap();
let auth_path = dir.path().join("auth.json");
let stale_auth = json!({
"OPENAI_API_KEY": "sk-old",
"tokens": {
"id_token": "stale.header.payload",
"access_token": "stale-access",
"refresh_token": "stale-refresh",
"account_id": "stale-acc"
}
});
std::fs::write(
&auth_path,
serde_json::to_string_pretty(&stale_auth).unwrap(),
)
.unwrap();
super::login_with_api_key(dir.path(), "sk-new", AuthCredentialsStoreMode::File)
.expect("login_with_api_key should succeed");
let storage = FileAuthStorage::new(dir.path().to_path_buf());
let auth = storage
.try_read_auth_json(&auth_path)
.expect("auth.json should parse");
assert_eq!(auth.openai_api_key.as_deref(), Some("sk-new"));
assert!(auth.tokens.is_none(), "tokens should be cleared");
}
#[test]
fn missing_auth_json_returns_none() {
let dir = tempdir().unwrap();
let auth = CodexAuth::from_auth_storage(dir.path(), AuthCredentialsStoreMode::File)
.expect("call should succeed");
assert_eq!(auth, None);
}
#[tokio::test]
#[serial(codex_api_key)]
async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
let codex_home = tempdir().unwrap();
let fake_jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.unwrap()
.unwrap();
assert_eq!(None, auth.api_key());
assert_eq!(AuthMode::Chatgpt, auth.auth_mode());
assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-12345"));
let auth_dot_json = auth
.get_current_auth_json()
.expect("AuthDotJson should exist");
let last_refresh = auth_dot_json
.last_refresh
.expect("last_refresh should be recorded");
assert_eq!(
AuthDotJson {
auth_mode: None,
openai_api_key: None,
tokens: Some(TokenData {
id_token: IdTokenInfo {
email: Some("user@example.com".to_string()),
chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)),
chatgpt_user_id: Some("user-12345".to_string()),
chatgpt_account_id: None,
raw_jwt: fake_jwt,
},
access_token: "test-access-token".to_string(),
refresh_token: "test-refresh-token".to_string(),
account_id: None,
}),
last_refresh: Some(last_refresh),
},
auth_dot_json
);
}
#[tokio::test]
#[serial(codex_api_key)]
async fn loads_api_key_from_auth_json() {
let dir = tempdir().unwrap();
let auth_file = dir.path().join("auth.json");
std::fs::write(
auth_file,
r#"{"OPENAI_API_KEY":"sk-test-key","tokens":null,"last_refresh":null}"#,
)
.unwrap();
let auth = super::load_auth(dir.path(), false, AuthCredentialsStoreMode::File)
.unwrap()
.unwrap();
assert_eq!(auth.auth_mode(), AuthMode::ApiKey);
assert_eq!(auth.api_key(), Some("sk-test-key"));
assert!(auth.get_token_data().is_err());
}
#[test]
fn logout_removes_auth_file() -> Result<(), std::io::Error> {
let dir = tempdir()?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(ApiAuthMode::ApiKey),
openai_api_key: Some("sk-test-key".to_string()),
tokens: None,
last_refresh: None,
};
super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?;
let auth_file = get_auth_file(dir.path());
assert!(auth_file.exists());
assert!(logout(dir.path(), AuthCredentialsStoreMode::File)?);
assert!(!auth_file.exists());
Ok(())
}
struct AuthFileParams {
openai_api_key: Option<String>,
chatgpt_plan_type: Option<String>,
chatgpt_account_id: Option<String>,
}
fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result<String> {
let auth_file = get_auth_file(codex_home);
// Create a minimal valid JWT for the id_token field.
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let mut auth_payload = serde_json::json!({
"chatgpt_user_id": "user-12345",
"user_id": "user-12345",
});
if let Some(chatgpt_plan_type) = params.chatgpt_plan_type {
auth_payload["chatgpt_plan_type"] = serde_json::Value::String(chatgpt_plan_type);
}
if let Some(chatgpt_account_id) = params.chatgpt_account_id {
let org_value = serde_json::Value::String(chatgpt_account_id);
auth_payload["chatgpt_account_id"] = org_value;
}
let payload = serde_json::json!({
"email": "user@example.com",
"email_verified": true,
"https://api.openai.com/auth": auth_payload,
});
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
let header_b64 = b64(&serde_json::to_vec(&header)?);
let payload_b64 = b64(&serde_json::to_vec(&payload)?);
let signature_b64 = b64(b"sig");
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
let auth_json_data = json!({
"OPENAI_API_KEY": params.openai_api_key,
"tokens": {
"id_token": fake_jwt,
"access_token": "test-access-token",
"refresh_token": "test-refresh-token"
},
"last_refresh": Utc::now(),
});
let auth_json = serde_json::to_string_pretty(&auth_json_data)?;
std::fs::write(auth_file, auth_json)?;
Ok(fake_jwt)
}
async fn build_config(
codex_home: &Path,
forced_login_method: Option<ForcedLoginMethod>,
forced_chatgpt_workspace_id: Option<String>,
) -> Config {
let mut config = ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
.expect("config should load");
config.forced_login_method = forced_login_method;
config.forced_chatgpt_workspace_id = forced_chatgpt_workspace_id;
config
}
/// Use sparingly.
/// TODO (gpeal): replace this with an injectable env var provider.
#[cfg(test)]
struct EnvVarGuard {
key: &'static str,
original: Option<std::ffi::OsString>,
}
#[cfg(test)]
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let original = env::var_os(key);
unsafe {
env::set_var(key, value);
}
Self { key, original }
}
}
#[cfg(test)]
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(value) => env::set_var(self.key, value),
None => env::remove_var(self.key),
}
}
}
}
#[tokio::test]
async fn enforce_login_restrictions_logs_out_for_method_mismatch() {
let codex_home = tempdir().unwrap();
login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File)
.expect("seed api key");
let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None).await;
let err = super::enforce_login_restrictions(&config)
.expect_err("expected method mismatch to error");
assert!(err.to_string().contains("ChatGPT login is required"));
assert!(
!codex_home.path().join("auth.json").exists(),
"auth.json should be removed on mismatch"
);
}
#[tokio::test]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("org_another_org".to_string()),
},
codex_home.path(),
)
.expect("failed to write auth file");
let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await;
let err = super::enforce_login_restrictions(&config)
.expect_err("expected workspace mismatch to error");
assert!(err.to_string().contains("workspace org_mine"));
assert!(
!codex_home.path().join("auth.json").exists(),
"auth.json should be removed on mismatch"
);
}
#[tokio::test]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_allows_matching_workspace() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("org_mine".to_string()),
},
codex_home.path(),
)
.expect("failed to write auth file");
let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await;
super::enforce_login_restrictions(&config).expect("matching workspace should succeed");
assert!(
codex_home.path().join("auth.json").exists(),
"auth.json should remain when restrictions pass"
);
}
#[tokio::test]
async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set()
{
let codex_home = tempdir().unwrap();
login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File)
.expect("seed api key");
let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await;
super::enforce_login_restrictions(&config).expect("matching workspace should succeed");
assert!(
codex_home.path().join("auth.json").exists(),
"auth.json should remain when restrictions pass"
);
}
#[tokio::test]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() {
let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env");
let codex_home = tempdir().unwrap();
let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None).await;
let err = super::enforce_login_restrictions(&config)
.expect_err("environment API key should not satisfy forced ChatGPT login");
assert!(
err.to_string()
.contains("ChatGPT login is required, but an API key is currently being used.")
);
}
#[test]
fn plan_type_maps_known_plan() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth available");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro));
}
#[test]
fn plan_type_maps_unknown_to_unknown() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("mystery-tier".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth available");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown));
}
#[test]
fn missing_plan_type_maps_to_unknown() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: None,
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth available");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown));
}
}
#[path = "auth_tests.rs"]
mod tests;
+2 -424
View File
@@ -332,427 +332,5 @@ fn create_auth_storage_with_keyring_store(
}
#[cfg(test)]
mod tests {
use super::*;
use crate::token_data::IdTokenInfo;
use anyhow::Context;
use base64::Engine;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::tempdir;
use codex_keyring_store::tests::MockKeyringStore;
use keyring::Error as KeyringError;
#[tokio::test]
async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("test-key".to_string()),
tokens: None,
last_refresh: Some(Utc::now()),
};
storage
.save(&auth_dot_json)
.context("failed to save auth file")?;
let loaded = storage.load().context("failed to load auth file")?;
assert_eq!(Some(auth_dot_json), loaded);
Ok(())
}
#[tokio::test]
async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("test-key".to_string()),
tokens: None,
last_refresh: Some(Utc::now()),
};
let file = get_auth_file(codex_home.path());
storage
.save(&auth_dot_json)
.context("failed to save auth file")?;
let same_auth_dot_json = storage
.try_read_auth_json(&file)
.context("failed to read auth file after save")?;
assert_eq!(auth_dot_json, same_auth_dot_json);
Ok(())
}
#[test]
fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> {
let dir = tempdir()?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("sk-test-key".to_string()),
tokens: None,
last_refresh: None,
};
let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File);
storage.save(&auth_dot_json)?;
assert!(dir.path().join("auth.json").exists());
let storage = FileAuthStorage::new(dir.path().to_path_buf());
let removed = storage.delete()?;
assert!(removed);
assert!(!dir.path().join("auth.json").exists());
Ok(())
}
#[test]
fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> {
let dir = tempdir()?;
let storage = create_auth_storage(
dir.path().to_path_buf(),
AuthCredentialsStoreMode::Ephemeral,
);
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("sk-ephemeral".to_string()),
tokens: None,
last_refresh: Some(Utc::now()),
};
storage.save(&auth_dot_json)?;
let loaded = storage.load()?;
assert_eq!(Some(auth_dot_json), loaded);
let removed = storage.delete()?;
assert!(removed);
let loaded = storage.load()?;
assert_eq!(None, loaded);
assert!(!get_auth_file(dir.path()).exists());
Ok(())
}
fn seed_keyring_and_fallback_auth_file_for_delete<F>(
mock_keyring: &MockKeyringStore,
codex_home: &Path,
compute_key: F,
) -> anyhow::Result<(String, PathBuf)>
where
F: FnOnce() -> std::io::Result<String>,
{
let key = compute_key()?;
mock_keyring.save(KEYRING_SERVICE, &key, "{}")?;
let auth_file = get_auth_file(codex_home);
std::fs::write(&auth_file, "stale")?;
Ok((key, auth_file))
}
fn seed_keyring_with_auth<F>(
mock_keyring: &MockKeyringStore,
compute_key: F,
auth: &AuthDotJson,
) -> anyhow::Result<()>
where
F: FnOnce() -> std::io::Result<String>,
{
let key = compute_key()?;
let serialized = serde_json::to_string(auth)?;
mock_keyring.save(KEYRING_SERVICE, &key, &serialized)?;
Ok(())
}
fn assert_keyring_saved_auth_and_removed_fallback(
mock_keyring: &MockKeyringStore,
key: &str,
codex_home: &Path,
expected: &AuthDotJson,
) {
let saved_value = mock_keyring
.saved_value(key)
.expect("keyring entry should exist");
let expected_serialized = serde_json::to_string(expected).expect("serialize expected auth");
assert_eq!(saved_value, expected_serialized);
let auth_file = get_auth_file(codex_home);
assert!(
!auth_file.exists(),
"fallback auth.json should be removed after keyring save"
);
}
fn id_token_with_prefix(prefix: &str) -> IdTokenInfo {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = json!({
"email": format!("{prefix}@example.com"),
"https://api.openai.com/auth": {
"chatgpt_account_id": format!("{prefix}-account"),
},
});
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
let signature_b64 = encode(b"sig");
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
crate::token_data::parse_chatgpt_jwt_claims(&fake_jwt).expect("fake JWT should parse")
}
fn auth_with_prefix(prefix: &str) -> AuthDotJson {
AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some(format!("{prefix}-api-key")),
tokens: Some(TokenData {
id_token: id_token_with_prefix(prefix),
access_token: format!("{prefix}-access"),
refresh_token: format!("{prefix}-refresh"),
account_id: Some(format!("{prefix}-account-id")),
}),
last_refresh: None,
}
}
#[test]
fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = KeyringAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let expected = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("sk-test".to_string()),
tokens: None,
last_refresh: None,
};
seed_keyring_with_auth(
&mock_keyring,
|| compute_store_key(codex_home.path()),
&expected,
)?;
let loaded = storage.load()?;
assert_eq!(Some(expected), loaded);
Ok(())
}
#[test]
fn keyring_auth_storage_compute_store_key_for_home_directory() -> anyhow::Result<()> {
let codex_home = PathBuf::from("~/.codex");
let key = compute_store_key(codex_home.as_path())?;
assert_eq!(key, "cli|940db7b1d0e4eb40");
Ok(())
}
#[test]
fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = KeyringAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let auth_file = get_auth_file(codex_home.path());
std::fs::write(&auth_file, "stale")?;
let auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(TokenData {
id_token: Default::default(),
access_token: "access".to_string(),
refresh_token: "refresh".to_string(),
account_id: Some("account".to_string()),
}),
last_refresh: Some(Utc::now()),
};
storage.save(&auth)?;
let key = compute_store_key(codex_home.path())?;
assert_keyring_saved_auth_and_removed_fallback(
&mock_keyring,
&key,
codex_home.path(),
&auth,
);
Ok(())
}
#[test]
fn keyring_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = KeyringAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let (key, auth_file) = seed_keyring_and_fallback_auth_file_for_delete(
&mock_keyring,
codex_home.path(),
|| compute_store_key(codex_home.path()),
)?;
let removed = storage.delete()?;
assert!(removed, "delete should report removal");
assert!(
!mock_keyring.contains(&key),
"keyring entry should be removed"
);
assert!(
!auth_file.exists(),
"fallback auth.json should be removed after keyring delete"
);
Ok(())
}
#[test]
fn auto_auth_storage_load_prefers_keyring_value() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let keyring_auth = auth_with_prefix("keyring");
seed_keyring_with_auth(
&mock_keyring,
|| compute_store_key(codex_home.path()),
&keyring_auth,
)?;
let file_auth = auth_with_prefix("file");
storage.file_storage.save(&file_auth)?;
let loaded = storage.load()?;
assert_eq!(loaded, Some(keyring_auth));
Ok(())
}
#[test]
fn auto_auth_storage_load_uses_file_when_keyring_empty() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(codex_home.path().to_path_buf(), Arc::new(mock_keyring));
let expected = auth_with_prefix("file-only");
storage.file_storage.save(&expected)?;
let loaded = storage.load()?;
assert_eq!(loaded, Some(expected));
Ok(())
}
#[test]
fn auto_auth_storage_load_falls_back_when_keyring_errors() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let key = compute_store_key(codex_home.path())?;
mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "load".into()));
let expected = auth_with_prefix("fallback");
storage.file_storage.save(&expected)?;
let loaded = storage.load()?;
assert_eq!(loaded, Some(expected));
Ok(())
}
#[test]
fn auto_auth_storage_save_prefers_keyring() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let key = compute_store_key(codex_home.path())?;
let stale = auth_with_prefix("stale");
storage.file_storage.save(&stale)?;
let expected = auth_with_prefix("to-save");
storage.save(&expected)?;
assert_keyring_saved_auth_and_removed_fallback(
&mock_keyring,
&key,
codex_home.path(),
&expected,
);
Ok(())
}
#[test]
fn auto_auth_storage_save_falls_back_when_keyring_errors() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let key = compute_store_key(codex_home.path())?;
mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "save".into()));
let auth = auth_with_prefix("fallback");
storage.save(&auth)?;
let auth_file = get_auth_file(codex_home.path());
assert!(
auth_file.exists(),
"fallback auth.json should be created when keyring save fails"
);
let saved = storage
.file_storage
.load()?
.context("fallback auth should exist")?;
assert_eq!(saved, auth);
assert!(
mock_keyring.saved_value(&key).is_none(),
"keyring should not contain value when save fails"
);
Ok(())
}
#[test]
fn auto_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let (key, auth_file) = seed_keyring_and_fallback_auth_file_for_delete(
&mock_keyring,
codex_home.path(),
|| compute_store_key(codex_home.path()),
)?;
let removed = storage.delete()?;
assert!(removed, "delete should report removal");
assert!(
!mock_keyring.contains(&key),
"keyring entry should be removed"
);
assert!(
!auth_file.exists(),
"fallback auth.json should be removed after delete"
);
Ok(())
}
}
#[path = "storage_tests.rs"]
mod tests;
+415
View File
@@ -0,0 +1,415 @@
use super::*;
use crate::token_data::IdTokenInfo;
use anyhow::Context;
use base64::Engine;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::tempdir;
use codex_keyring_store::tests::MockKeyringStore;
use keyring::Error as KeyringError;
#[tokio::test]
async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("test-key".to_string()),
tokens: None,
last_refresh: Some(Utc::now()),
};
storage
.save(&auth_dot_json)
.context("failed to save auth file")?;
let loaded = storage.load().context("failed to load auth file")?;
assert_eq!(Some(auth_dot_json), loaded);
Ok(())
}
#[tokio::test]
async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("test-key".to_string()),
tokens: None,
last_refresh: Some(Utc::now()),
};
let file = get_auth_file(codex_home.path());
storage
.save(&auth_dot_json)
.context("failed to save auth file")?;
let same_auth_dot_json = storage
.try_read_auth_json(&file)
.context("failed to read auth file after save")?;
assert_eq!(auth_dot_json, same_auth_dot_json);
Ok(())
}
#[test]
fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> {
let dir = tempdir()?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("sk-test-key".to_string()),
tokens: None,
last_refresh: None,
};
let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File);
storage.save(&auth_dot_json)?;
assert!(dir.path().join("auth.json").exists());
let storage = FileAuthStorage::new(dir.path().to_path_buf());
let removed = storage.delete()?;
assert!(removed);
assert!(!dir.path().join("auth.json").exists());
Ok(())
}
#[test]
fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> {
let dir = tempdir()?;
let storage = create_auth_storage(
dir.path().to_path_buf(),
AuthCredentialsStoreMode::Ephemeral,
);
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("sk-ephemeral".to_string()),
tokens: None,
last_refresh: Some(Utc::now()),
};
storage.save(&auth_dot_json)?;
let loaded = storage.load()?;
assert_eq!(Some(auth_dot_json), loaded);
let removed = storage.delete()?;
assert!(removed);
let loaded = storage.load()?;
assert_eq!(None, loaded);
assert!(!get_auth_file(dir.path()).exists());
Ok(())
}
fn seed_keyring_and_fallback_auth_file_for_delete<F>(
mock_keyring: &MockKeyringStore,
codex_home: &Path,
compute_key: F,
) -> anyhow::Result<(String, PathBuf)>
where
F: FnOnce() -> std::io::Result<String>,
{
let key = compute_key()?;
mock_keyring.save(KEYRING_SERVICE, &key, "{}")?;
let auth_file = get_auth_file(codex_home);
std::fs::write(&auth_file, "stale")?;
Ok((key, auth_file))
}
fn seed_keyring_with_auth<F>(
mock_keyring: &MockKeyringStore,
compute_key: F,
auth: &AuthDotJson,
) -> anyhow::Result<()>
where
F: FnOnce() -> std::io::Result<String>,
{
let key = compute_key()?;
let serialized = serde_json::to_string(auth)?;
mock_keyring.save(KEYRING_SERVICE, &key, &serialized)?;
Ok(())
}
fn assert_keyring_saved_auth_and_removed_fallback(
mock_keyring: &MockKeyringStore,
key: &str,
codex_home: &Path,
expected: &AuthDotJson,
) {
let saved_value = mock_keyring
.saved_value(key)
.expect("keyring entry should exist");
let expected_serialized = serde_json::to_string(expected).expect("serialize expected auth");
assert_eq!(saved_value, expected_serialized);
let auth_file = get_auth_file(codex_home);
assert!(
!auth_file.exists(),
"fallback auth.json should be removed after keyring save"
);
}
fn id_token_with_prefix(prefix: &str) -> IdTokenInfo {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = json!({
"email": format!("{prefix}@example.com"),
"https://api.openai.com/auth": {
"chatgpt_account_id": format!("{prefix}-account"),
},
});
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
let signature_b64 = encode(b"sig");
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
crate::token_data::parse_chatgpt_jwt_claims(&fake_jwt).expect("fake JWT should parse")
}
fn auth_with_prefix(prefix: &str) -> AuthDotJson {
AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some(format!("{prefix}-api-key")),
tokens: Some(TokenData {
id_token: id_token_with_prefix(prefix),
access_token: format!("{prefix}-access"),
refresh_token: format!("{prefix}-refresh"),
account_id: Some(format!("{prefix}-account-id")),
}),
last_refresh: None,
}
}
#[test]
fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = KeyringAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let expected = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("sk-test".to_string()),
tokens: None,
last_refresh: None,
};
seed_keyring_with_auth(
&mock_keyring,
|| compute_store_key(codex_home.path()),
&expected,
)?;
let loaded = storage.load()?;
assert_eq!(Some(expected), loaded);
Ok(())
}
#[test]
fn keyring_auth_storage_compute_store_key_for_home_directory() -> anyhow::Result<()> {
let codex_home = PathBuf::from("~/.codex");
let key = compute_store_key(codex_home.as_path())?;
assert_eq!(key, "cli|940db7b1d0e4eb40");
Ok(())
}
#[test]
fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = KeyringAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let auth_file = get_auth_file(codex_home.path());
std::fs::write(&auth_file, "stale")?;
let auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(TokenData {
id_token: Default::default(),
access_token: "access".to_string(),
refresh_token: "refresh".to_string(),
account_id: Some("account".to_string()),
}),
last_refresh: Some(Utc::now()),
};
storage.save(&auth)?;
let key = compute_store_key(codex_home.path())?;
assert_keyring_saved_auth_and_removed_fallback(&mock_keyring, &key, codex_home.path(), &auth);
Ok(())
}
#[test]
fn keyring_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = KeyringAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let (key, auth_file) =
seed_keyring_and_fallback_auth_file_for_delete(&mock_keyring, codex_home.path(), || {
compute_store_key(codex_home.path())
})?;
let removed = storage.delete()?;
assert!(removed, "delete should report removal");
assert!(
!mock_keyring.contains(&key),
"keyring entry should be removed"
);
assert!(
!auth_file.exists(),
"fallback auth.json should be removed after keyring delete"
);
Ok(())
}
#[test]
fn auto_auth_storage_load_prefers_keyring_value() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let keyring_auth = auth_with_prefix("keyring");
seed_keyring_with_auth(
&mock_keyring,
|| compute_store_key(codex_home.path()),
&keyring_auth,
)?;
let file_auth = auth_with_prefix("file");
storage.file_storage.save(&file_auth)?;
let loaded = storage.load()?;
assert_eq!(loaded, Some(keyring_auth));
Ok(())
}
#[test]
fn auto_auth_storage_load_uses_file_when_keyring_empty() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(codex_home.path().to_path_buf(), Arc::new(mock_keyring));
let expected = auth_with_prefix("file-only");
storage.file_storage.save(&expected)?;
let loaded = storage.load()?;
assert_eq!(loaded, Some(expected));
Ok(())
}
#[test]
fn auto_auth_storage_load_falls_back_when_keyring_errors() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let key = compute_store_key(codex_home.path())?;
mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "load".into()));
let expected = auth_with_prefix("fallback");
storage.file_storage.save(&expected)?;
let loaded = storage.load()?;
assert_eq!(loaded, Some(expected));
Ok(())
}
#[test]
fn auto_auth_storage_save_prefers_keyring() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let key = compute_store_key(codex_home.path())?;
let stale = auth_with_prefix("stale");
storage.file_storage.save(&stale)?;
let expected = auth_with_prefix("to-save");
storage.save(&expected)?;
assert_keyring_saved_auth_and_removed_fallback(
&mock_keyring,
&key,
codex_home.path(),
&expected,
);
Ok(())
}
#[test]
fn auto_auth_storage_save_falls_back_when_keyring_errors() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let key = compute_store_key(codex_home.path())?;
mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "save".into()));
let auth = auth_with_prefix("fallback");
storage.save(&auth)?;
let auth_file = get_auth_file(codex_home.path());
assert!(
auth_file.exists(),
"fallback auth.json should be created when keyring save fails"
);
let saved = storage
.file_storage
.load()?
.context("fallback auth should exist")?;
assert_eq!(saved, auth);
assert!(
mock_keyring.saved_value(&key).is_none(),
"keyring should not contain value when save fails"
);
Ok(())
}
#[test]
fn auto_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let (key, auth_file) =
seed_keyring_and_fallback_auth_file_for_delete(&mock_keyring, codex_home.path(), || {
compute_store_key(codex_home.path())
})?;
let removed = storage.delete()?;
assert!(removed, "delete should report removal");
assert!(
!mock_keyring.contains(&key),
"keyring entry should be removed"
);
assert!(
!auth_file.exists(),
"fallback auth.json should be removed after delete"
);
Ok(())
}
+432
View File
@@ -0,0 +1,432 @@
use super::*;
use crate::auth::storage::FileAuthStorage;
use crate::auth::storage::get_auth_file;
use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::token_data::IdTokenInfo;
use crate::token_data::KnownPlan as InternalKnownPlan;
use crate::token_data::PlanType as InternalPlanType;
use codex_protocol::account::PlanType as AccountPlanType;
use base64::Engine;
use codex_protocol::config_types::ForcedLoginMethod;
use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn refresh_without_id_token() {
let codex_home = tempdir().unwrap();
let fake_jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let storage = create_auth_storage(
codex_home.path().to_path_buf(),
AuthCredentialsStoreMode::File,
);
let updated = super::persist_tokens(
&storage,
None,
Some("new-access-token".to_string()),
Some("new-refresh-token".to_string()),
)
.expect("update_tokens should succeed");
let tokens = updated.tokens.expect("tokens should exist");
assert_eq!(tokens.id_token.raw_jwt, fake_jwt);
assert_eq!(tokens.access_token, "new-access-token");
assert_eq!(tokens.refresh_token, "new-refresh-token");
}
#[test]
fn login_with_api_key_overwrites_existing_auth_json() {
let dir = tempdir().unwrap();
let auth_path = dir.path().join("auth.json");
let stale_auth = json!({
"OPENAI_API_KEY": "sk-old",
"tokens": {
"id_token": "stale.header.payload",
"access_token": "stale-access",
"refresh_token": "stale-refresh",
"account_id": "stale-acc"
}
});
std::fs::write(
&auth_path,
serde_json::to_string_pretty(&stale_auth).unwrap(),
)
.unwrap();
super::login_with_api_key(dir.path(), "sk-new", AuthCredentialsStoreMode::File)
.expect("login_with_api_key should succeed");
let storage = FileAuthStorage::new(dir.path().to_path_buf());
let auth = storage
.try_read_auth_json(&auth_path)
.expect("auth.json should parse");
assert_eq!(auth.openai_api_key.as_deref(), Some("sk-new"));
assert!(auth.tokens.is_none(), "tokens should be cleared");
}
#[test]
fn missing_auth_json_returns_none() {
let dir = tempdir().unwrap();
let auth = CodexAuth::from_auth_storage(dir.path(), AuthCredentialsStoreMode::File)
.expect("call should succeed");
assert_eq!(auth, None);
}
#[tokio::test]
#[serial(codex_api_key)]
async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
let codex_home = tempdir().unwrap();
let fake_jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.unwrap()
.unwrap();
assert_eq!(None, auth.api_key());
assert_eq!(AuthMode::Chatgpt, auth.auth_mode());
assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-12345"));
let auth_dot_json = auth
.get_current_auth_json()
.expect("AuthDotJson should exist");
let last_refresh = auth_dot_json
.last_refresh
.expect("last_refresh should be recorded");
assert_eq!(
AuthDotJson {
auth_mode: None,
openai_api_key: None,
tokens: Some(TokenData {
id_token: IdTokenInfo {
email: Some("user@example.com".to_string()),
chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)),
chatgpt_user_id: Some("user-12345".to_string()),
chatgpt_account_id: None,
raw_jwt: fake_jwt,
},
access_token: "test-access-token".to_string(),
refresh_token: "test-refresh-token".to_string(),
account_id: None,
}),
last_refresh: Some(last_refresh),
},
auth_dot_json
);
}
#[tokio::test]
#[serial(codex_api_key)]
async fn loads_api_key_from_auth_json() {
let dir = tempdir().unwrap();
let auth_file = dir.path().join("auth.json");
std::fs::write(
auth_file,
r#"{"OPENAI_API_KEY":"sk-test-key","tokens":null,"last_refresh":null}"#,
)
.unwrap();
let auth = super::load_auth(dir.path(), false, AuthCredentialsStoreMode::File)
.unwrap()
.unwrap();
assert_eq!(auth.auth_mode(), AuthMode::ApiKey);
assert_eq!(auth.api_key(), Some("sk-test-key"));
assert!(auth.get_token_data().is_err());
}
#[test]
fn logout_removes_auth_file() -> Result<(), std::io::Error> {
let dir = tempdir()?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(ApiAuthMode::ApiKey),
openai_api_key: Some("sk-test-key".to_string()),
tokens: None,
last_refresh: None,
};
super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?;
let auth_file = get_auth_file(dir.path());
assert!(auth_file.exists());
assert!(logout(dir.path(), AuthCredentialsStoreMode::File)?);
assert!(!auth_file.exists());
Ok(())
}
struct AuthFileParams {
openai_api_key: Option<String>,
chatgpt_plan_type: Option<String>,
chatgpt_account_id: Option<String>,
}
fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result<String> {
let auth_file = get_auth_file(codex_home);
// Create a minimal valid JWT for the id_token field.
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let mut auth_payload = serde_json::json!({
"chatgpt_user_id": "user-12345",
"user_id": "user-12345",
});
if let Some(chatgpt_plan_type) = params.chatgpt_plan_type {
auth_payload["chatgpt_plan_type"] = serde_json::Value::String(chatgpt_plan_type);
}
if let Some(chatgpt_account_id) = params.chatgpt_account_id {
let org_value = serde_json::Value::String(chatgpt_account_id);
auth_payload["chatgpt_account_id"] = org_value;
}
let payload = serde_json::json!({
"email": "user@example.com",
"email_verified": true,
"https://api.openai.com/auth": auth_payload,
});
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
let header_b64 = b64(&serde_json::to_vec(&header)?);
let payload_b64 = b64(&serde_json::to_vec(&payload)?);
let signature_b64 = b64(b"sig");
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
let auth_json_data = json!({
"OPENAI_API_KEY": params.openai_api_key,
"tokens": {
"id_token": fake_jwt,
"access_token": "test-access-token",
"refresh_token": "test-refresh-token"
},
"last_refresh": Utc::now(),
});
let auth_json = serde_json::to_string_pretty(&auth_json_data)?;
std::fs::write(auth_file, auth_json)?;
Ok(fake_jwt)
}
async fn build_config(
codex_home: &Path,
forced_login_method: Option<ForcedLoginMethod>,
forced_chatgpt_workspace_id: Option<String>,
) -> Config {
let mut config = ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
.expect("config should load");
config.forced_login_method = forced_login_method;
config.forced_chatgpt_workspace_id = forced_chatgpt_workspace_id;
config
}
/// Use sparingly.
/// TODO (gpeal): replace this with an injectable env var provider.
#[cfg(test)]
struct EnvVarGuard {
key: &'static str,
original: Option<std::ffi::OsString>,
}
#[cfg(test)]
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let original = env::var_os(key);
unsafe {
env::set_var(key, value);
}
Self { key, original }
}
}
#[cfg(test)]
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(value) => env::set_var(self.key, value),
None => env::remove_var(self.key),
}
}
}
}
#[tokio::test]
async fn enforce_login_restrictions_logs_out_for_method_mismatch() {
let codex_home = tempdir().unwrap();
login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File)
.expect("seed api key");
let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None).await;
let err =
super::enforce_login_restrictions(&config).expect_err("expected method mismatch to error");
assert!(err.to_string().contains("ChatGPT login is required"));
assert!(
!codex_home.path().join("auth.json").exists(),
"auth.json should be removed on mismatch"
);
}
#[tokio::test]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("org_another_org".to_string()),
},
codex_home.path(),
)
.expect("failed to write auth file");
let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await;
let err = super::enforce_login_restrictions(&config)
.expect_err("expected workspace mismatch to error");
assert!(err.to_string().contains("workspace org_mine"));
assert!(
!codex_home.path().join("auth.json").exists(),
"auth.json should be removed on mismatch"
);
}
#[tokio::test]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_allows_matching_workspace() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("org_mine".to_string()),
},
codex_home.path(),
)
.expect("failed to write auth file");
let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await;
super::enforce_login_restrictions(&config).expect("matching workspace should succeed");
assert!(
codex_home.path().join("auth.json").exists(),
"auth.json should remain when restrictions pass"
);
}
#[tokio::test]
async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set()
{
let codex_home = tempdir().unwrap();
login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File)
.expect("seed api key");
let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await;
super::enforce_login_restrictions(&config).expect("matching workspace should succeed");
assert!(
codex_home.path().join("auth.json").exists(),
"auth.json should remain when restrictions pass"
);
}
#[tokio::test]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() {
let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env");
let codex_home = tempdir().unwrap();
let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None).await;
let err = super::enforce_login_restrictions(&config)
.expect_err("environment API key should not satisfy forced ChatGPT login");
assert!(
err.to_string()
.contains("ChatGPT login is required, but an API key is currently being used.")
);
}
#[test]
fn plan_type_maps_known_plan() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth available");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro));
}
#[test]
fn plan_type_maps_unknown_to_unknown() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("mystery-tier".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth available");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown));
}
#[test]
fn missing_plan_type_maps_to_unknown() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: None,
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth available");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown));
}
+2 -98
View File
@@ -1261,101 +1261,5 @@ impl WebsocketTelemetry for ApiTelemetry {
}
#[cfg(test)]
mod tests {
use super::ModelClient;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
use serde_json::json;
fn test_model_client(session_source: SessionSource) -> ModelClient {
let provider = crate::model_provider_info::create_oss_provider_with_base_url(
"https://example.com/v1",
crate::model_provider_info::WireApi::Responses,
);
ModelClient::new(
None,
ThreadId::new(),
provider,
session_source,
None,
false,
false,
false,
None,
)
}
fn test_model_info() -> ModelInfo {
serde_json::from_value(json!({
"slug": "gpt-test",
"display_name": "gpt-test",
"description": "desc",
"default_reasoning_level": "medium",
"supported_reasoning_levels": [
{"effort": "medium", "description": "medium"}
],
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": 1,
"upgrade": null,
"base_instructions": "base instructions",
"model_messages": null,
"supports_reasoning_summaries": false,
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
"truncation_policy": {"mode": "bytes", "limit": 10000},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"context_window": 272000,
"auto_compact_token_limit": null,
"experimental_supported_tools": []
}))
.expect("deserialize test model info")
}
fn test_session_telemetry() -> SessionTelemetry {
SessionTelemetry::new(
ThreadId::new(),
"gpt-test",
"gpt-test",
None,
None,
None,
"test-originator".to_string(),
false,
"test-terminal".to_string(),
SessionSource::Cli,
)
}
#[test]
fn build_subagent_headers_sets_other_subagent_label() {
let client = test_model_client(SessionSource::SubAgent(SubAgentSource::Other(
"memory_consolidation".to_string(),
)));
let headers = client.build_subagent_headers();
let value = headers
.get("x-openai-subagent")
.and_then(|value| value.to_str().ok());
assert_eq!(value, Some("memory_consolidation"));
}
#[tokio::test]
async fn summarize_memories_returns_empty_for_empty_input() {
let client = test_model_client(SessionSource::Cli);
let model_info = test_model_info();
let session_telemetry = test_session_telemetry();
let output = client
.summarize_memories(Vec::new(), &model_info, None, &session_telemetry)
.await
.expect("empty summarize request should succeed");
assert_eq!(output.len(), 0);
}
}
#[path = "client_tests.rs"]
mod tests;
+35 -197
View File
@@ -169,6 +169,12 @@ pub(crate) mod tools {
pub(crate) enum ToolSpec {
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "tool_search")]
ToolSearch {
execution: String,
description: String,
parameters: JsonSchema,
},
#[serde(rename = "local_shell")]
LocalShell {},
#[serde(rename = "image_generation")]
@@ -198,6 +204,7 @@ pub(crate) mod tools {
pub(crate) fn name(&self) -> &str {
match self {
ToolSpec::Function(tool) => tool.name.as_str(),
ToolSpec::ToolSearch { .. } => "tool_search",
ToolSpec::LocalShell {} => "local_shell",
ToolSpec::ImageGeneration { .. } => "image_generation",
ToolSpec::WebSearch { .. } => "web_search",
@@ -268,10 +275,36 @@ pub(crate) mod tools {
/// `required` and `additional_properties` must be present. All fields in
/// `properties` must be present in `required`.
pub(crate) strict: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) defer_loading: Option<bool>,
pub(crate) parameters: JsonSchema,
#[serde(skip)]
pub(crate) output_schema: Option<Value>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub(crate) enum ToolSearchOutputTool {
#[allow(dead_code)]
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "namespace")]
Namespace(ResponsesApiNamespace),
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub(crate) struct ResponsesApiNamespace {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) tools: Vec<ResponsesApiNamespaceTool>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub(crate) enum ResponsesApiNamespaceTool {
#[serde(rename = "function")]
Function(ResponsesApiTool),
}
}
pub struct ResponseStream {
@@ -287,200 +320,5 @@ impl Stream for ResponseStream {
}
#[cfg(test)]
mod tests {
use codex_api::ResponsesApiRequest;
use codex_api::common::OpenAiVerbosity;
use codex_api::common::TextControls;
use codex_api::create_text_param_for_request;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::FunctionCallOutputPayload;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn serializes_text_verbosity_when_set() {
let input: Vec<ResponseItem> = vec![];
let tools: Vec<serde_json::Value> = vec![];
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
input,
tools,
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,
service_tier: None,
text: Some(TextControls {
verbosity: Some(OpenAiVerbosity::Low),
format: None,
}),
};
let v = serde_json::to_value(&req).expect("json");
assert_eq!(
v.get("text")
.and_then(|t| t.get("verbosity"))
.and_then(|s| s.as_str()),
Some("low")
);
}
#[test]
fn serializes_text_schema_with_strict_format() {
let input: Vec<ResponseItem> = vec![];
let tools: Vec<serde_json::Value> = vec![];
let schema = serde_json::json!({
"type": "object",
"properties": {
"answer": {"type": "string"}
},
"required": ["answer"],
});
let text_controls =
create_text_param_for_request(None, &Some(schema.clone())).expect("text controls");
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
input,
tools,
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,
service_tier: None,
text: Some(text_controls),
};
let v = serde_json::to_value(&req).expect("json");
let text = v.get("text").expect("text field");
assert!(text.get("verbosity").is_none());
let format = text.get("format").expect("format field");
assert_eq!(
format.get("name"),
Some(&serde_json::Value::String("codex_output_schema".into()))
);
assert_eq!(
format.get("type"),
Some(&serde_json::Value::String("json_schema".into()))
);
assert_eq!(format.get("strict"), Some(&serde_json::Value::Bool(true)));
assert_eq!(format.get("schema"), Some(&schema));
}
#[test]
fn omits_text_when_not_set() {
let input: Vec<ResponseItem> = vec![];
let tools: Vec<serde_json::Value> = vec![];
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
input,
tools,
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,
service_tier: None,
text: None,
};
let v = serde_json::to_value(&req).expect("json");
assert!(v.get("text").is_none());
}
#[test]
fn serializes_flex_service_tier_when_set() {
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
input: vec![],
tools: vec![],
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,
service_tier: Some(ServiceTier::Flex.to_string()),
text: None,
};
let v = serde_json::to_value(&req).expect("json");
assert_eq!(
v.get("service_tier").and_then(|tier| tier.as_str()),
Some("flex")
);
}
#[test]
fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#;
let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello";
let mut items = vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
];
reserialize_shell_outputs(&mut items);
assert_eq!(
items,
vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
]
);
}
}
#[path = "client_common_tests.rs"]
mod tests;
+243
View File
@@ -0,0 +1,243 @@
use codex_api::ResponsesApiRequest;
use codex_api::common::OpenAiVerbosity;
use codex_api::common::TextControls;
use codex_api::create_text_param_for_request;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::FunctionCallOutputPayload;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn serializes_text_verbosity_when_set() {
let input: Vec<ResponseItem> = vec![];
let tools: Vec<serde_json::Value> = vec![];
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
input,
tools,
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,
service_tier: None,
text: Some(TextControls {
verbosity: Some(OpenAiVerbosity::Low),
format: None,
}),
};
let v = serde_json::to_value(&req).expect("json");
assert_eq!(
v.get("text")
.and_then(|t| t.get("verbosity"))
.and_then(|s| s.as_str()),
Some("low")
);
}
#[test]
fn serializes_text_schema_with_strict_format() {
let input: Vec<ResponseItem> = vec![];
let tools: Vec<serde_json::Value> = vec![];
let schema = serde_json::json!({
"type": "object",
"properties": {
"answer": {"type": "string"}
},
"required": ["answer"],
});
let text_controls =
create_text_param_for_request(None, &Some(schema.clone())).expect("text controls");
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
input,
tools,
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,
service_tier: None,
text: Some(text_controls),
};
let v = serde_json::to_value(&req).expect("json");
let text = v.get("text").expect("text field");
assert!(text.get("verbosity").is_none());
let format = text.get("format").expect("format field");
assert_eq!(
format.get("name"),
Some(&serde_json::Value::String("codex_output_schema".into()))
);
assert_eq!(
format.get("type"),
Some(&serde_json::Value::String("json_schema".into()))
);
assert_eq!(format.get("strict"), Some(&serde_json::Value::Bool(true)));
assert_eq!(format.get("schema"), Some(&schema));
}
#[test]
fn omits_text_when_not_set() {
let input: Vec<ResponseItem> = vec![];
let tools: Vec<serde_json::Value> = vec![];
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
input,
tools,
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,
service_tier: None,
text: None,
};
let v = serde_json::to_value(&req).expect("json");
assert!(v.get("text").is_none());
}
#[test]
fn serializes_flex_service_tier_when_set() {
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
input: vec![],
tools: vec![],
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,
service_tier: Some(ServiceTier::Flex.to_string()),
text: None,
};
let v = serde_json::to_value(&req).expect("json");
assert_eq!(
v.get("service_tier").and_then(|tier| tier.as_str()),
Some("flex")
);
}
#[test]
fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#;
let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello";
let mut items = vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
];
reserialize_shell_outputs(&mut items);
assert_eq!(
items,
vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
]
);
}
#[test]
fn tool_search_output_namespace_serializes_with_deferred_child_tools() {
let namespace = tools::ToolSearchOutputTool::Namespace(tools::ResponsesApiNamespace {
name: "mcp__codex_apps__calendar".to_string(),
description: "Plan events".to_string(),
tools: vec![tools::ResponsesApiNamespaceTool::Function(
tools::ResponsesApiTool {
name: "create_event".to_string(),
description: "Create a calendar event.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
},
)],
});
let value = serde_json::to_value(namespace).expect("serialize namespace");
assert_eq!(
value,
serde_json::json!({
"type": "namespace",
"name": "mcp__codex_apps__calendar",
"description": "Plan events",
"tools": [
{
"type": "function",
"name": "create_event",
"description": "Create a calendar event.",
"strict": false,
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {}
}
}
]
})
);
}
+96
View File
@@ -0,0 +1,96 @@
use super::ModelClient;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
use serde_json::json;
fn test_model_client(session_source: SessionSource) -> ModelClient {
let provider = crate::model_provider_info::create_oss_provider_with_base_url(
"https://example.com/v1",
crate::model_provider_info::WireApi::Responses,
);
ModelClient::new(
None,
ThreadId::new(),
provider,
session_source,
None,
false,
false,
false,
None,
)
}
fn test_model_info() -> ModelInfo {
serde_json::from_value(json!({
"slug": "gpt-test",
"display_name": "gpt-test",
"description": "desc",
"default_reasoning_level": "medium",
"supported_reasoning_levels": [
{"effort": "medium", "description": "medium"}
],
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": 1,
"upgrade": null,
"base_instructions": "base instructions",
"model_messages": null,
"supports_reasoning_summaries": false,
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
"truncation_policy": {"mode": "bytes", "limit": 10000},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"context_window": 272000,
"auto_compact_token_limit": null,
"experimental_supported_tools": []
}))
.expect("deserialize test model info")
}
fn test_session_telemetry() -> SessionTelemetry {
SessionTelemetry::new(
ThreadId::new(),
"gpt-test",
"gpt-test",
None,
None,
None,
"test-originator".to_string(),
false,
"test-terminal".to_string(),
SessionSource::Cli,
)
}
#[test]
fn build_subagent_headers_sets_other_subagent_label() {
let client = test_model_client(SessionSource::SubAgent(SubAgentSource::Other(
"memory_consolidation".to_string(),
)));
let headers = client.build_subagent_headers();
let value = headers
.get("x-openai-subagent")
.and_then(|value| value.to_str().ok());
assert_eq!(value, Some("memory_consolidation"));
}
#[tokio::test]
async fn summarize_memories_returns_empty_for_empty_input() {
let client = test_model_client(SessionSource::Cli);
let model_info = test_model_info();
let session_telemetry = test_session_telemetry();
let output = client
.summarize_memories(Vec::new(), &model_info, None, &session_telemetry)
.await
.expect("empty summarize request should succeed");
assert_eq!(output.len(), 0);
}
+189 -138
View File
@@ -106,6 +106,7 @@ use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnContextNetworkItem;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionsArgs;
use codex_protocol::request_permissions::RequestPermissionsEvent;
@@ -120,6 +121,7 @@ use codex_utils_stream_parser::ProposedPlanSegment;
use codex_utils_stream_parser::extract_proposed_plan_text;
use codex_utils_stream_parser::strip_citations;
use futures::future::BoxFuture;
use futures::future::Shared;
use futures::prelude::*;
use futures::stream::FuturesOrdered;
use rmcp::model::ListResourceTemplatesResult;
@@ -209,8 +211,6 @@ use crate::mcp::maybe_prompt_and_install_mcp_dependencies;
use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::mcp_connection_manager::filter_codex_apps_mcp_tools_only;
use crate::mcp_connection_manager::filter_mcp_tools_by_name;
use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only;
use crate::memories;
use crate::mentions::build_connector_slug_counts;
@@ -289,13 +289,14 @@ use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::js_repl::JsReplHandle;
use crate::tools::js_repl::resolve_compatible_node;
use crate::tools::network_approval::NetworkApprovalService;
use crate::tools::network_approval::build_blocked_request_observer;
use crate::tools::network_approval::build_network_policy_decider;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::router::ToolRouterParams;
use crate::tools::sandboxing::ApprovalStore;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::ToolsConfigParams;
@@ -335,8 +336,13 @@ pub struct Codex {
// Last known status of the agent.
pub(crate) agent_status: watch::Receiver<AgentStatus>,
pub(crate) session: Arc<Session>,
// Shared future for the background submission loop completion so multiple
// callers can wait for shutdown.
pub(crate) session_loop_termination: SessionLoopTermination,
}
pub(crate) type SessionLoopTermination = Shared<BoxFuture<'static, ()>>;
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
/// the submission id for the initial `ConfigureSession` request and the
/// unique session id.
@@ -347,6 +353,24 @@ pub struct CodexSpawnOk {
pub conversation_id: ThreadId,
}
pub(crate) struct CodexSpawnArgs {
pub(crate) config: Config,
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) models_manager: Arc<ModelsManager>,
pub(crate) skills_manager: Arc<SkillsManager>,
pub(crate) plugins_manager: Arc<PluginsManager>,
pub(crate) mcp_manager: Arc<McpManager>,
pub(crate) file_watcher: Arc<FileWatcher>,
pub(crate) conversation_history: InitialHistory,
pub(crate) session_source: SessionSource,
pub(crate) agent_control: AgentControl,
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
pub(crate) persist_extended_history: bool,
pub(crate) metrics_service_name: Option<String>,
pub(crate) inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
pub(crate) parent_trace: Option<W3cTraceContext>,
}
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 512;
const CYBER_VERIFY_URL: &str = "https://chatgpt.com/cyber";
@@ -354,23 +378,48 @@ const CYBER_SAFETY_URL: &str = "https://developers.openai.com/codex/concepts/cyb
impl Codex {
/// Spawn a new [`Codex`] and initialize the session.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn spawn(
mut config: Config,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
file_watcher: Arc<FileWatcher>,
conversation_history: InitialHistory,
session_source: SessionSource,
agent_control: AgentControl,
dynamic_tools: Vec<DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
) -> CodexResult<CodexSpawnOk> {
pub(crate) async fn spawn(args: CodexSpawnArgs) -> CodexResult<CodexSpawnOk> {
let parent_trace = match args.parent_trace {
Some(trace) => {
if codex_otel::context_from_w3c_trace_context(&trace).is_some() {
Some(trace)
} else {
warn!("ignoring invalid thread spawn trace carrier");
None
}
}
None => None,
};
let thread_spawn_span = info_span!("thread_spawn", otel.name = "thread_spawn");
if let Some(trace) = parent_trace.as_ref() {
let _ = set_parent_from_w3c_trace_context(&thread_spawn_span, trace);
}
Self::spawn_internal(CodexSpawnArgs {
parent_trace,
..args
})
.instrument(thread_spawn_span)
.await
}
async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult<CodexSpawnOk> {
let CodexSpawnArgs {
mut config,
auth_manager,
models_manager,
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
conversation_history,
session_source,
agent_control,
dynamic_tools,
persist_extended_history,
metrics_service_name,
inherited_shell_snapshot,
parent_trace: _,
} = args;
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
@@ -562,15 +611,18 @@ impl Codex {
let thread_id = session.conversation_id;
// This task will run until Op::Shutdown is received.
let session_loop_span = info_span!("session_loop", thread_id = %thread_id);
tokio::spawn(
submission_loop(Arc::clone(&session), config, rx_sub).instrument(session_loop_span),
);
let session_for_loop = Arc::clone(&session);
let session_loop_handle = tokio::spawn(async move {
submission_loop(session_for_loop, config, rx_sub)
.instrument(info_span!("session_loop", thread_id = %thread_id))
.await;
});
let codex = Codex {
tx_sub,
rx_event,
agent_status: agent_status_rx,
session,
session_loop_termination: session_loop_termination_from_handle(session_loop_handle),
};
#[allow(deprecated)]
@@ -583,11 +635,19 @@ impl Codex {
/// Submit the `op` wrapped in a `Submission` with a unique ID.
pub async fn submit(&self, op: Op) -> CodexResult<String> {
self.submit_with_trace(op, None).await
}
pub async fn submit_with_trace(
&self,
op: Op,
trace: Option<W3cTraceContext>,
) -> CodexResult<String> {
let id = Uuid::now_v7().to_string();
let sub = Submission {
id: id.clone(),
op,
trace: None,
trace,
};
self.submit_with_id(sub).await?;
Ok(id)
@@ -606,6 +666,17 @@ impl Codex {
Ok(())
}
pub async fn shutdown_and_wait(&self) -> CodexResult<()> {
let session_loop_termination = self.session_loop_termination.clone();
match self.submit(Op::Shutdown).await {
Ok(_) => {}
Err(CodexErr::InternalAgentDied) => {}
Err(err) => return Err(err),
}
session_loop_termination.await;
Ok(())
}
pub async fn next_event(&self) -> CodexResult<Event> {
let event = self
.rx_event
@@ -653,6 +724,21 @@ impl Codex {
}
}
#[cfg(test)]
pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination {
futures::future::ready(()).boxed().shared()
}
pub(crate) fn session_loop_termination_from_handle(
handle: JoinHandle<()>,
) -> SessionLoopTermination {
async move {
let _ = handle.await;
}
.boxed()
.shared()
}
/// Context for an initialized model agent
///
/// A session has at most 1 running task at a time, and can be interrupted by user input.
@@ -1206,9 +1292,6 @@ impl Session {
cwd.clone(),
session_configuration.sandbox_policy.get(),
session_configuration.windows_sandbox_level,
per_turn_config
.features
.enabled(Feature::UseLinuxSandboxBwrap),
));
let (current_date, timezone) = local_time_context();
TurnContext {
@@ -1653,7 +1736,9 @@ impl Session {
config.features.enabled(Feature::RuntimeMetrics),
Self::build_model_client_beta_features_header(config.as_ref()),
),
code_mode_store: Default::default(),
code_mode_service: crate::tools::code_mode::CodeModeService::new(
config.js_repl_node_path.clone(),
),
};
let js_repl = Arc::new(JsReplHandle::with_node_path(
config.js_repl_node_path.clone(),
@@ -1716,7 +1801,7 @@ impl Session {
sandbox_policy: session_configuration.sandbox_policy.get().clone(),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: session_configuration.cwd.clone(),
use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap),
use_legacy_landlock: config.features.use_legacy_landlock(),
};
let mut required_mcp_servers: Vec<String> = mcp_servers
.iter()
@@ -1882,26 +1967,6 @@ impl Session {
}
}
pub(crate) async fn merge_mcp_tool_selection(&self, tool_names: Vec<String>) -> Vec<String> {
let mut state = self.state.lock().await;
state.merge_mcp_tool_selection(tool_names)
}
pub(crate) async fn set_mcp_tool_selection(&self, tool_names: Vec<String>) {
let mut state = self.state.lock().await;
state.set_mcp_tool_selection(tool_names);
}
pub(crate) async fn get_mcp_tool_selection(&self) -> Option<Vec<String>> {
let state = self.state.lock().await;
state.get_mcp_tool_selection()
}
pub(crate) async fn clear_mcp_tool_selection(&self) {
let mut state = self.state.lock().await;
state.clear_mcp_tool_selection();
}
// Merges connector IDs into the session-level explicit connector selection.
pub(crate) async fn merge_connector_selection(
&self,
@@ -1925,7 +1990,6 @@ impl Session {
async fn record_initial_history(&self, conversation_history: InitialHistory) {
let turn_context = self.new_default_turn().await;
self.clear_mcp_tool_selection().await;
let is_subagent = {
let state = self.state.lock().await;
matches!(
@@ -1941,8 +2005,6 @@ impl Session {
}
InitialHistory::Resumed(resumed_history) => {
let rollout_items = resumed_history.history;
let restored_tool_selection =
Self::extract_mcp_tool_selection_from_rollout(&rollout_items);
let reconstructed_rollout = self
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
@@ -1988,9 +2050,6 @@ impl Session {
let mut state = self.state.lock().await;
state.set_token_info(Some(info));
}
if let Some(selected_tools) = restored_tool_selection {
self.set_mcp_tool_selection(selected_tools).await;
}
// Defer seeding the session's initial context until the first turn starts so
// turn/start overrides can be merged before we write to the rollout.
@@ -1999,9 +2058,6 @@ impl Session {
}
}
InitialHistory::Forked(rollout_items) => {
let restored_tool_selection =
Self::extract_mcp_tool_selection_from_rollout(&rollout_items);
let reconstructed_rollout = self
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
.await;
@@ -2029,9 +2085,6 @@ impl Session {
let mut state = self.state.lock().await;
state.set_token_info(Some(info));
}
if let Some(selected_tools) = restored_tool_selection {
self.set_mcp_tool_selection(selected_tools).await;
}
// If persisting, persist all rollout items as-is (recorder filters)
if !rollout_items.is_empty() {
@@ -2065,54 +2118,6 @@ impl Session {
})
}
fn extract_mcp_tool_selection_from_rollout(
rollout_items: &[RolloutItem],
) -> Option<Vec<String>> {
let mut search_call_ids = HashSet::new();
let mut active_selected_tools: Option<Vec<String>> = None;
for item in rollout_items {
let RolloutItem::ResponseItem(response_item) = item else {
continue;
};
match response_item {
ResponseItem::FunctionCall { name, call_id, .. } => {
if name == SEARCH_TOOL_BM25_TOOL_NAME {
search_call_ids.insert(call_id.clone());
}
}
ResponseItem::FunctionCallOutput { call_id, output } => {
if !search_call_ids.contains(call_id) {
continue;
}
let Some(content) = output.body.to_text() else {
continue;
};
let Ok(payload) = serde_json::from_str::<Value>(&content) else {
continue;
};
let Some(selected_tools) = payload
.get("active_selected_tools")
.and_then(Value::as_array)
else {
continue;
};
let Some(selected_tools) = selected_tools
.iter()
.map(|value| value.as_str().map(str::to_string))
.collect::<Option<Vec<_>>>()
else {
continue;
};
active_selected_tools = Some(selected_tools);
}
_ => {}
}
}
active_selected_tools
}
async fn previous_turn_settings(&self) -> Option<PreviousTurnSettings> {
let state = self.state.lock().await;
state.previous_turn_settings()
@@ -2269,9 +2274,7 @@ impl Session {
sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(),
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: per_turn_config.cwd.clone(),
use_linux_sandbox_bwrap: per_turn_config
.features
.enabled(Feature::UseLinuxSandboxBwrap),
use_legacy_landlock: per_turn_config.features.use_legacy_landlock(),
};
if let Err(e) = self
.services
@@ -3900,7 +3903,20 @@ impl Session {
.await
}
pub(crate) async fn parse_mcp_tool_name(&self, tool_name: &str) -> Option<(String, String)> {
pub(crate) async fn parse_mcp_tool_name(
&self,
name: &str,
namespace: &Option<String>,
) -> Option<(String, String)> {
let tool_name = if let Some(namespace) = namespace {
if name.starts_with(namespace.as_str()) {
name
} else {
&format!("{namespace}{name}")
}
} else {
name
};
self.services
.mcp_connection_manager
.read()
@@ -3965,7 +3981,7 @@ impl Session {
sandbox_policy: turn_context.sandbox_policy.get().clone(),
codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(),
sandbox_cwd: turn_context.cwd.clone(),
use_linux_sandbox_bwrap: turn_context.features.enabled(Feature::UseLinuxSandboxBwrap),
use_legacy_landlock: turn_context.features.use_legacy_landlock(),
};
{
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
@@ -5242,9 +5258,6 @@ async fn spawn_review_thread(
parent_turn_context.cwd.clone(),
parent_turn_context.sandbox_policy.get(),
parent_turn_context.windows_sandbox_level,
parent_turn_context
.features
.enabled(Feature::UseLinuxSandboxBwrap),
));
let review_turn_context = TurnContext {
@@ -5585,6 +5598,11 @@ pub(crate) async fn run_turn(
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
// many turns, from the perspective of the user, it is a single turn.
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let _code_mode_worker = sess
.services
.code_mode_service
.start_turn_worker(&sess, &turn_context, &turn_diff_tracker)
.await;
let mut server_model_warning_emitted_for_turn = false;
// `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse
@@ -6118,7 +6136,7 @@ fn filter_codex_apps_mcp_tools(
.iter()
.filter(|(_, tool)| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return true;
return false;
}
let Some(connector_id) = codex_apps_connector_id(tool) else {
return false;
@@ -6279,7 +6297,7 @@ async fn run_sampling_request(
}
}
async fn built_tools(
pub(crate) async fn built_tools(
sess: &Session,
turn_context: &TurnContext,
input: &[ResponseItem],
@@ -6302,10 +6320,17 @@ async fn built_tools(
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
let connectors = if turn_context.apps_enabled() {
let apps_enabled = turn_context.apps_enabled();
let accessible_connectors =
apps_enabled.then(|| connectors::accessible_connectors_from_mcp_tools(&mcp_tools));
let accessible_connectors_with_enabled_state =
accessible_connectors.as_ref().map(|connectors| {
connectors::with_app_enabled_state(connectors.clone(), &turn_context.config)
});
let connectors = if apps_enabled {
let connectors = connectors::merge_plugin_apps_with_accessible(
loaded_plugins.effective_apps(),
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
accessible_connectors.clone().unwrap_or_default(),
);
Some(connectors::with_app_enabled_state(
connectors,
@@ -6314,6 +6339,34 @@ async fn built_tools(
} else {
None
};
let auth = sess.services.auth_manager.auth().await;
let discoverable_tools = if apps_enabled
&& turn_context.tools_config.search_tool
&& turn_context.tools_config.tool_suggest
{
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
match connectors::list_tool_suggest_discoverable_tools_with_auth(
&turn_context.config,
auth.as_ref(),
accessible_connectors.as_slice(),
)
.await
{
Ok(connectors) if connectors.is_empty() => None,
Ok(connectors) => {
Some(connectors.into_iter().map(DiscoverableTool::from).collect())
}
Err(err) => {
warn!("failed to load discoverable tool suggestions: {err:#}");
None
}
}
} else {
None
}
} else {
None
};
// Keep the connector-grouped app view around for the router even though
// app tools only become prompt-visible after explicit selection/discovery.
@@ -6334,30 +6387,28 @@ async fn built_tools(
);
let mut selected_mcp_tools = filter_non_codex_apps_mcp_tools_only(&mcp_tools);
if let Some(selected_tools) = sess.get_mcp_tool_selection().await {
selected_mcp_tools.extend(filter_mcp_tools_by_name(&mcp_tools, &selected_tools));
}
selected_mcp_tools.extend(filter_codex_apps_mcp_tools_only(
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
explicitly_enabled.as_ref(),
&turn_context.config,
));
mcp_tools =
connectors::filter_codex_apps_tools_by_policy(selected_mcp_tools, &turn_context.config);
mcp_tools = selected_mcp_tools;
}
Ok(Arc::new(ToolRouter::from_config(
&turn_context.tools_config,
has_mcp_servers.then(|| {
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect()
}),
app_tools,
turn_context.dynamic_tools.as_slice(),
ToolRouterParams {
mcp_tools: has_mcp_servers.then(|| {
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect()
}),
app_tools,
discoverable_tools,
dynamic_tools: turn_context.dynamic_tools.as_slice(),
},
)))
}
+23 -232
View File
@@ -27,6 +27,7 @@ use tokio_util::sync::CancellationToken;
use crate::AuthManager;
use crate::codex::Codex;
use crate::codex::CodexSpawnArgs;
use crate::codex::CodexSpawnOk;
use crate::codex::SUBMISSION_CHANNEL_CAPACITY;
use crate::codex::Session;
@@ -36,6 +37,9 @@ use crate::error::CodexErr;
use crate::models_manager::manager::ModelsManager;
use codex_protocol::protocol::InitialHistory;
#[cfg(test)]
use crate::codex::completed_session_loop_termination;
/// Start an interactive sub-Codex thread and return IO channels.
///
/// The returned `events_rx` yields non-approval events emitted by the sub-agent.
@@ -55,22 +59,23 @@ pub(crate) async fn run_codex_thread_interactive(
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let CodexSpawnOk { codex, .. } = Codex::spawn(
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
config,
auth_manager,
models_manager,
Arc::clone(&parent_session.services.skills_manager),
Arc::clone(&parent_session.services.plugins_manager),
Arc::clone(&parent_session.services.mcp_manager),
Arc::clone(&parent_session.services.file_watcher),
initial_history.unwrap_or(InitialHistory::New),
SessionSource::SubAgent(subagent_source),
parent_session.services.agent_control.clone(),
Vec::new(),
false,
None,
None,
)
skills_manager: Arc::clone(&parent_session.services.skills_manager),
plugins_manager: Arc::clone(&parent_session.services.plugins_manager),
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
file_watcher: Arc::clone(&parent_session.services.file_watcher),
conversation_history: initial_history.unwrap_or(InitialHistory::New),
session_source: SessionSource::SubAgent(subagent_source),
agent_control: parent_session.services.agent_control.clone(),
dynamic_tools: Vec::new(),
persist_extended_history: false,
metrics_service_name: None,
inherited_shell_snapshot: None,
parent_trace: None,
})
.await?;
let codex = Arc::new(codex);
@@ -105,6 +110,7 @@ pub(crate) async fn run_codex_thread_interactive(
rx_event: rx_sub,
agent_status: codex.agent_status.clone(),
session: Arc::clone(&codex.session),
session_loop_termination: codex.session_loop_termination.clone(),
})
}
@@ -151,6 +157,7 @@ pub(crate) async fn run_codex_thread_one_shot(
let ops_tx = io.tx_sub.clone();
let agent_status = io.agent_status.clone();
let session = Arc::clone(&io.session);
let session_loop_termination = io.session_loop_termination.clone();
let io_for_bridge = io;
tokio::spawn(async move {
while let Ok(event) = io_for_bridge.next_event().await {
@@ -184,6 +191,7 @@ pub(crate) async fn run_codex_thread_one_shot(
tx_sub: tx_closed,
agent_status,
session,
session_loop_termination,
})
}
@@ -545,222 +553,5 @@ where
}
#[cfg(test)]
mod tests {
use super::*;
use async_channel::bounded;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RawResponseItemEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use pretty_assertions::assert_eq;
use tokio::sync::watch;
#[tokio::test]
async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
let (tx_events, rx_events) = bounded(1);
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await;
let codex = Arc::new(Codex {
tx_sub,
rx_event: rx_events,
agent_status,
session: Arc::clone(&session),
});
let (tx_out, rx_out) = bounded(1);
tx_out
.send(Event {
id: "full".to_string(),
msg: EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
}),
})
.await
.unwrap();
let cancel = CancellationToken::new();
let forward = tokio::spawn(forward_events(
Arc::clone(&codex),
tx_out.clone(),
session,
ctx,
cancel.clone(),
));
tx_events
.send(Event {
id: "evt".to_string(),
msg: EventMsg::RawResponseItem(RawResponseItemEvent {
item: ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-1".to_string(),
name: "tool".to_string(),
input: "{}".to_string(),
},
}),
})
.await
.unwrap();
drop(tx_events);
cancel.cancel();
timeout(std::time::Duration::from_millis(1000), forward)
.await
.expect("forward_events hung")
.expect("forward_events join error");
let received = rx_out.recv().await.expect("prefilled event missing");
assert_eq!("full", received.id);
let mut ops = Vec::new();
while let Ok(sub) = rx_sub.try_recv() {
ops.push(sub.op);
}
assert!(
ops.iter().any(|op| matches!(op, Op::Interrupt)),
"expected Interrupt op after cancellation"
);
assert!(
ops.iter().any(|op| matches!(op, Op::Shutdown)),
"expected Shutdown op after cancellation"
);
}
#[tokio::test]
async fn forward_ops_preserves_submission_trace_context() {
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_tx_events, rx_events) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let (session, _ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await;
let codex = Arc::new(Codex {
tx_sub,
rx_event: rx_events,
agent_status,
session,
});
let (tx_ops, rx_ops) = bounded(1);
let cancel = CancellationToken::new();
let forward = tokio::spawn(forward_ops(Arc::clone(&codex), rx_ops, cancel));
let submission = Submission {
id: "sub-1".to_string(),
op: Op::Interrupt,
trace: Some(codex_protocol::protocol::W3cTraceContext {
traceparent: Some(
"00-1234567890abcdef1234567890abcdef-1234567890abcdef-01".to_string(),
),
tracestate: Some("vendor=state".to_string()),
}),
};
tx_ops.send(submission.clone()).await.unwrap();
drop(tx_ops);
let forwarded = timeout(Duration::from_secs(1), rx_sub.recv())
.await
.expect("forward_ops hung")
.expect("forwarded submission missing");
assert_eq!(submission.id, forwarded.id);
assert_eq!(submission.op, forwarded.op);
assert_eq!(submission.trace, forwarded.trace);
timeout(Duration::from_secs(1), forward)
.await
.expect("forward_ops did not exit")
.expect("forward_ops join error");
}
#[tokio::test]
async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
let (parent_session, parent_ctx, rx_events) =
crate::codex::make_session_and_context_with_rx().await;
*parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let codex = Arc::new(Codex {
tx_sub,
rx_event: rx_events_child,
agent_status,
session: Arc::clone(&parent_session),
});
let call_id = "tool-call-1".to_string();
let expected_response = RequestPermissionsResponse {
permissions: PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
..PermissionProfile::default()
},
scope: PermissionGrantScope::Turn,
};
let cancel_token = CancellationToken::new();
let request_call_id = call_id.clone();
let handle = tokio::spawn({
let codex = Arc::clone(&codex);
let parent_session = Arc::clone(&parent_session);
let parent_ctx = Arc::clone(&parent_ctx);
let cancel_token = cancel_token.clone();
async move {
handle_request_permissions(
codex.as_ref(),
parent_session.as_ref(),
parent_ctx.as_ref(),
RequestPermissionsEvent {
call_id: request_call_id,
turn_id: "child-turn-1".to_string(),
reason: Some("need access".to_string()),
permissions: PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
..PermissionProfile::default()
},
},
&cancel_token,
)
.await;
}
});
let request_event = timeout(Duration::from_secs(1), rx_events.recv())
.await
.expect("request_permissions event timed out")
.expect("request_permissions event missing");
let EventMsg::RequestPermissions(request) = request_event.msg else {
panic!("expected RequestPermissions event");
};
assert_eq!(request.call_id, call_id.clone());
parent_session
.notify_request_permissions_response(&call_id, expected_response.clone())
.await;
timeout(Duration::from_secs(1), handle)
.await
.expect("handle_request_permissions hung")
.expect("handle_request_permissions join error");
let submission = timeout(Duration::from_secs(1), rx_sub.recv())
.await
.expect("request_permissions response timed out")
.expect("request_permissions response missing");
assert_eq!(
submission.op,
Op::RequestPermissionsResponse {
id: call_id,
response: expected_response,
}
);
}
}
#[path = "codex_delegate_tests.rs"]
mod tests;
+220
View File
@@ -0,0 +1,220 @@
use super::*;
use async_channel::bounded;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RawResponseItemEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use pretty_assertions::assert_eq;
use tokio::sync::watch;
#[tokio::test]
async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
let (tx_events, rx_events) = bounded(1);
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await;
let codex = Arc::new(Codex {
tx_sub,
rx_event: rx_events,
agent_status,
session: Arc::clone(&session),
session_loop_termination: completed_session_loop_termination(),
});
let (tx_out, rx_out) = bounded(1);
tx_out
.send(Event {
id: "full".to_string(),
msg: EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
}),
})
.await
.unwrap();
let cancel = CancellationToken::new();
let forward = tokio::spawn(forward_events(
Arc::clone(&codex),
tx_out.clone(),
session,
ctx,
cancel.clone(),
));
tx_events
.send(Event {
id: "evt".to_string(),
msg: EventMsg::RawResponseItem(RawResponseItemEvent {
item: ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-1".to_string(),
name: "tool".to_string(),
input: "{}".to_string(),
},
}),
})
.await
.unwrap();
drop(tx_events);
cancel.cancel();
timeout(std::time::Duration::from_millis(1000), forward)
.await
.expect("forward_events hung")
.expect("forward_events join error");
let received = rx_out.recv().await.expect("prefilled event missing");
assert_eq!("full", received.id);
let mut ops = Vec::new();
while let Ok(sub) = rx_sub.try_recv() {
ops.push(sub.op);
}
assert!(
ops.iter().any(|op| matches!(op, Op::Interrupt)),
"expected Interrupt op after cancellation"
);
assert!(
ops.iter().any(|op| matches!(op, Op::Shutdown)),
"expected Shutdown op after cancellation"
);
}
#[tokio::test]
async fn forward_ops_preserves_submission_trace_context() {
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_tx_events, rx_events) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let (session, _ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await;
let codex = Arc::new(Codex {
tx_sub,
rx_event: rx_events,
agent_status,
session,
session_loop_termination: completed_session_loop_termination(),
});
let (tx_ops, rx_ops) = bounded(1);
let cancel = CancellationToken::new();
let forward = tokio::spawn(forward_ops(Arc::clone(&codex), rx_ops, cancel));
let submission = Submission {
id: "sub-1".to_string(),
op: Op::Interrupt,
trace: Some(codex_protocol::protocol::W3cTraceContext {
traceparent: Some(
"00-1234567890abcdef1234567890abcdef-1234567890abcdef-01".to_string(),
),
tracestate: Some("vendor=state".to_string()),
}),
};
tx_ops.send(submission.clone()).await.unwrap();
drop(tx_ops);
let forwarded = timeout(Duration::from_secs(1), rx_sub.recv())
.await
.expect("forward_ops hung")
.expect("forwarded submission missing");
assert_eq!(submission.id, forwarded.id);
assert_eq!(submission.op, forwarded.op);
assert_eq!(submission.trace, forwarded.trace);
timeout(Duration::from_secs(1), forward)
.await
.expect("forward_ops did not exit")
.expect("forward_ops join error");
}
#[tokio::test]
async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
let (parent_session, parent_ctx, rx_events) =
crate::codex::make_session_and_context_with_rx().await;
*parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let codex = Arc::new(Codex {
tx_sub,
rx_event: rx_events_child,
agent_status,
session: Arc::clone(&parent_session),
session_loop_termination: completed_session_loop_termination(),
});
let call_id = "tool-call-1".to_string();
let expected_response = RequestPermissionsResponse {
permissions: PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
..PermissionProfile::default()
},
scope: PermissionGrantScope::Turn,
};
let cancel_token = CancellationToken::new();
let request_call_id = call_id.clone();
let handle = tokio::spawn({
let codex = Arc::clone(&codex);
let parent_session = Arc::clone(&parent_session);
let parent_ctx = Arc::clone(&parent_ctx);
let cancel_token = cancel_token.clone();
async move {
handle_request_permissions(
codex.as_ref(),
parent_session.as_ref(),
parent_ctx.as_ref(),
RequestPermissionsEvent {
call_id: request_call_id,
turn_id: "child-turn-1".to_string(),
reason: Some("need access".to_string()),
permissions: PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
..PermissionProfile::default()
},
},
&cancel_token,
)
.await;
}
});
let request_event = timeout(Duration::from_secs(1), rx_events.recv())
.await
.expect("request_permissions event timed out")
.expect("request_permissions event missing");
let EventMsg::RequestPermissions(request) = request_event.msg else {
panic!("expected RequestPermissions event");
};
assert_eq!(request.call_id, call_id.clone());
parent_session
.notify_request_permissions_response(&call_id, expected_response.clone())
.await;
timeout(Duration::from_secs(1), handle)
.await
.expect("handle_request_permissions hung")
.expect("handle_request_permissions join error");
let submission = timeout(Duration::from_secs(1), rx_sub.recv())
.await
.expect("request_permissions response timed out")
.expect("request_permissions response missing");
assert_eq!(
submission.op,
Op::RequestPermissionsResponse {
id: call_id,
response: expected_response,
}
);
}
+143 -139
View File
@@ -168,9 +168,12 @@ fn default_image_save_developer_message_text() -> String {
fn test_tool_runtime(session: Arc<Session>, turn_context: Arc<TurnContext>) -> ToolCallRuntime {
let router = Arc::new(ToolRouter::from_config(
&turn_context.tools_config,
None,
None,
turn_context.dynamic_tools.as_slice(),
crate::tools::router::ToolRouterParams {
mcp_tools: None,
app_tools: None,
discoverable_tools: None,
dynamic_tools: turn_context.dynamic_tools.as_slice(),
},
));
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
ToolCallRuntime::new(router, session, turn_context, tracker)
@@ -262,9 +265,19 @@ fn make_mcp_tool(
connector_id: Option<&str>,
connector_name: Option<&str>,
) -> ToolInfo {
let tool_namespace = if server_name == CODEX_APPS_MCP_SERVER_NAME {
connector_name
.map(crate::connectors::sanitize_name)
.map(|connector_name| format!("mcp__{server_name}__{connector_name}"))
.unwrap_or_else(|| server_name.to_string())
} else {
server_name.to_string()
};
ToolInfo {
server_name: server_name.to_string(),
tool_name: tool_name.to_string(),
tool_namespace,
tool: Tool {
name: tool_name.to_string().into(),
title: None,
@@ -279,25 +292,10 @@ fn make_mcp_tool(
connector_id: connector_id.map(str::to_string),
connector_name: connector_name.map(str::to_string),
plugin_display_names: Vec::new(),
connector_description: None,
}
}
fn function_call_rollout_item(name: &str, call_id: &str) -> RolloutItem {
RolloutItem::ResponseItem(ResponseItem::FunctionCall {
id: None,
name: name.to_string(),
arguments: "{}".to_string(),
call_id: call_id.to_string(),
})
}
fn function_call_output_rollout_item(call_id: &str, output: &str) -> RolloutItem {
RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput {
call_id: call_id.to_string(),
output: FunctionCallOutputPayload::from_text(output.to_string()),
})
}
#[test]
fn validated_network_policy_amendment_host_allows_normalized_match() {
let amendment = NetworkPolicyAmendment {
@@ -550,8 +548,12 @@ fn non_app_mcp_tools_remain_visible_without_search_selection() {
&explicitly_enabled_connectors,
&HashMap::new(),
);
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors);
selected_mcp_tools.extend(apps_mcp_tools);
let config = test_config();
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
&connectors,
&config,
));
let mut tool_names: Vec<String> = selected_mcp_tools.into_keys().collect();
tool_names.sort();
@@ -560,7 +562,7 @@ fn non_app_mcp_tools_remain_visible_without_search_selection() {
#[test]
fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
let selected_tool_names = vec![
let selected_tool_names = [
"mcp__codex_apps__calendar_create_event".to_string(),
"mcp__rmcp__echo".to_string(),
];
@@ -580,7 +582,11 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
),
]);
let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names);
let mut selected_mcp_tools = mcp_tools
.iter()
.filter(|(name, _)| selected_tool_names.contains(name))
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect::<HashMap<_, _>>();
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
let explicitly_enabled_connectors = HashSet::new();
let connectors = filter_connectors_for_input(
@@ -589,8 +595,12 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
&explicitly_enabled_connectors,
&HashMap::new(),
);
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors);
selected_mcp_tools.extend(apps_mcp_tools);
let config = test_config();
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
&connectors,
&config,
));
let mut tool_names: Vec<String> = selected_mcp_tools.into_keys().collect();
tool_names.sort();
@@ -605,7 +615,7 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
#[test]
fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
let selected_tool_names = vec!["mcp__rmcp__echo".to_string()];
let selected_tool_names = ["mcp__rmcp__echo".to_string()];
let mcp_tools = HashMap::from([
(
"mcp__codex_apps__calendar_create_event".to_string(),
@@ -622,7 +632,11 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
),
]);
let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names);
let mut selected_mcp_tools = mcp_tools
.iter()
.filter(|(name, _)| selected_tool_names.contains(name))
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect::<HashMap<_, _>>();
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
let explicitly_enabled_connectors = HashSet::new();
let connectors = filter_connectors_for_input(
@@ -631,8 +645,12 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
&explicitly_enabled_connectors,
&HashMap::new(),
);
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors);
selected_mcp_tools.extend(apps_mcp_tools);
let config = test_config();
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
&connectors,
&config,
));
let mut tool_names: Vec<String> = selected_mcp_tools.into_keys().collect();
tool_names.sort();
@@ -645,106 +663,6 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_reads_search_tool_output() {
let rollout_items = vec![
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"),
function_call_output_rollout_item(
"search-1",
&json!({
"active_selected_tools": [
"mcp__codex_apps__calendar_create_event",
"mcp__codex_apps__calendar_list_events",
],
})
.to_string(),
),
];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(
selected,
Some(vec![
"mcp__codex_apps__calendar_create_event".to_string(),
"mcp__codex_apps__calendar_list_events".to_string(),
])
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_latest_valid_payload_wins() {
let rollout_items = vec![
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"),
function_call_output_rollout_item(
"search-1",
&json!({
"active_selected_tools": ["mcp__codex_apps__calendar_create_event"],
})
.to_string(),
),
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-2"),
function_call_output_rollout_item(
"search-2",
&json!({
"active_selected_tools": ["mcp__codex_apps__calendar_delete_event"],
})
.to_string(),
),
];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(
selected,
Some(vec!["mcp__codex_apps__calendar_delete_event".to_string(),])
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_ignores_non_search_and_malformed_payloads() {
let rollout_items = vec![
function_call_rollout_item("shell", "shell-1"),
function_call_output_rollout_item(
"shell-1",
&json!({
"active_selected_tools": ["mcp__codex_apps__should_be_ignored"],
})
.to_string(),
),
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"),
function_call_output_rollout_item("search-1", "{not-json"),
function_call_output_rollout_item(
"unknown-search-call",
&json!({
"active_selected_tools": ["mcp__codex_apps__also_ignored"],
})
.to_string(),
),
function_call_output_rollout_item(
"search-1",
&json!({
"active_selected_tools": ["mcp__codex_apps__calendar_list_events"],
})
.to_string(),
),
];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(
selected,
Some(vec!["mcp__codex_apps__calendar_list_events".to_string(),])
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_returns_none_without_valid_search_output() {
let rollout_items = vec![function_call_rollout_item(
SEARCH_TOOL_BM25_TOOL_NAME,
"search-1",
)];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(selected, None);
}
#[tokio::test]
async fn reconstruct_history_matches_live_compactions() {
let (session, turn_context) = make_session_and_context().await;
@@ -2252,7 +2170,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
config.features.enabled(Feature::RuntimeMetrics),
Session::build_model_client_beta_features_header(config.as_ref()),
),
code_mode_store: Default::default(),
code_mode_service: crate::tools::code_mode::CodeModeService::new(
config.js_repl_node_path.clone(),
),
};
let js_repl = Arc::new(JsReplHandle::with_node_path(
config.js_repl_node_path.clone(),
@@ -2452,6 +2372,7 @@ async fn submit_with_id_captures_current_span_trace_context() {
rx_event,
agent_status,
session: Arc::new(session),
session_loop_termination: completed_session_loop_termination(),
};
init_test_tracing();
@@ -2679,6 +2600,81 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
);
}
#[tokio::test]
async fn shutdown_and_wait_allows_multiple_waiters() {
let (session, _turn_context) = make_session_and_context().await;
let (tx_sub, rx_sub) = async_channel::bounded(4);
let (_tx_event, rx_event) = async_channel::unbounded();
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let session_loop_handle = tokio::spawn(async move {
let shutdown: Submission = rx_sub.recv().await.expect("shutdown submission");
assert_eq!(shutdown.op, Op::Shutdown);
tokio::time::sleep(StdDuration::from_millis(50)).await;
});
let codex = Arc::new(Codex {
tx_sub,
rx_event,
agent_status,
session: Arc::new(session),
session_loop_termination: session_loop_termination_from_handle(session_loop_handle),
});
let waiter_1 = {
let codex = Arc::clone(&codex);
tokio::spawn(async move { codex.shutdown_and_wait().await })
};
let waiter_2 = {
let codex = Arc::clone(&codex);
tokio::spawn(async move { codex.shutdown_and_wait().await })
};
waiter_1
.await
.expect("first shutdown waiter join")
.expect("first shutdown waiter");
waiter_2
.await
.expect("second shutdown waiter join")
.expect("second shutdown waiter");
}
#[tokio::test]
async fn shutdown_and_wait_waits_when_shutdown_is_already_in_progress() {
let (session, _turn_context) = make_session_and_context().await;
let (tx_sub, rx_sub) = async_channel::bounded(4);
drop(rx_sub);
let (_tx_event, rx_event) = async_channel::unbounded();
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let (shutdown_complete_tx, shutdown_complete_rx) = tokio::sync::oneshot::channel();
let session_loop_handle = tokio::spawn(async move {
let _ = shutdown_complete_rx.await;
});
let codex = Arc::new(Codex {
tx_sub,
rx_event,
agent_status,
session: Arc::new(session),
session_loop_termination: session_loop_termination_from_handle(session_loop_handle),
});
let waiter = {
let codex = Arc::clone(&codex);
tokio::spawn(async move { codex.shutdown_and_wait().await })
};
tokio::time::sleep(StdDuration::from_millis(10)).await;
assert!(!waiter.is_finished());
shutdown_complete_tx
.send(())
.expect("session loop should still be waiting to terminate");
waiter
.await
.expect("shutdown waiter join")
.expect("shutdown waiter");
}
pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
dynamic_tools: Vec<DynamicToolSpec>,
) -> (
@@ -2813,7 +2809,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
config.features.enabled(Feature::RuntimeMetrics),
Session::build_model_client_beta_features_header(config.as_ref()),
),
code_mode_store: Default::default(),
code_mode_service: crate::tools::code_mode::CodeModeService::new(
config.js_repl_node_path.clone(),
),
};
let js_repl = Arc::new(JsReplHandle::with_node_path(
config.js_repl_node_path.clone(),
@@ -3978,14 +3976,17 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
let app_tools = Some(tools.clone());
let router = ToolRouter::from_config(
&turn_context.tools_config,
Some(
tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
app_tools,
turn_context.dynamic_tools.as_slice(),
crate::tools::router::ToolRouterParams {
mcp_tools: Some(
tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
app_tools,
discoverable_tools: None,
dynamic_tools: turn_context.dynamic_tools.as_slice(),
},
);
let item = ResponseItem::CustomToolCall {
id: None,
@@ -4259,6 +4260,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
tracker: Arc::clone(&turn_diff_tracker),
call_id,
tool_name: tool_name.to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": params.command.clone(),
@@ -4302,6 +4304,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
tracker: Arc::clone(&turn_diff_tracker),
call_id: "test-call-2".to_string(),
tool_name: tool_name.to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": params2.command.clone(),
@@ -4357,6 +4360,7 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request()
tracker: Arc::clone(&tracker),
call_id: "exec-call".to_string(),
tool_name: "exec_command".to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"cmd": "echo hi",
+14 -9
View File
@@ -137,6 +137,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "test-call".to_string(),
tool_name: "shell".to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": params.command.clone(),
@@ -204,6 +205,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic
tracker: Arc::clone(&tracker),
call_id: "exec-call".to_string(),
tool_name: "exec_command".to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"cmd": "echo hi",
@@ -287,7 +289,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let file_watcher = Arc::new(FileWatcher::noop());
let CodexSpawnOk { codex, .. } = Codex::spawn(
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
config,
auth_manager,
models_manager,
@@ -295,14 +297,17 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
plugins_manager,
mcp_manager,
file_watcher,
InitialHistory::New,
SessionSource::SubAgent(SubAgentSource::Other(GUARDIAN_SUBAGENT_NAME.to_string())),
AgentControl::default(),
Vec::new(),
false,
None,
None,
)
conversation_history: InitialHistory::New,
session_source: SessionSource::SubAgent(SubAgentSource::Other(
GUARDIAN_SUBAGENT_NAME.to_string(),
)),
agent_control: AgentControl::default(),
dynamic_tools: Vec::new(),
persist_extended_history: false,
metrics_service_name: None,
inherited_shell_snapshot: None,
parent_trace: None,
})
.await
.expect("spawn guardian subagent");

Some files were not shown because too many files have changed in this diff Show More