diff --git a/.github/blob-size-allowlist.txt b/.github/blob-size-allowlist.txt new file mode 100644 index 0000000000..4c9462e8e2 --- /dev/null +++ b/.github/blob-size-allowlist.txt @@ -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 diff --git a/.github/workflows/blob-size-policy.yml b/.github/workflows/blob-size-policy.yml new file mode 100644 index 0000000000..441775c0e3 --- /dev/null +++ b/.github/workflows/blob-size-policy.yml @@ -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 diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 64abfaac3c..2969e75228 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 681487c099..77ffb61204 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -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" } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 2e7e64e186..c592ea8e3f 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 60f49aa556..c6228cd722 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 9868420f98..1e59da8e60 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c6471e785f..b35c03c0f1 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index ca6af0b958..25068b6e31 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index 050bcb9c50..6048b82242 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 66fd52c528..1841d3a247 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 8b62d11e18..01ae5febc4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json index daa8326443..a95f47cd3e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json @@ -45,18 +45,12 @@ "type": "array" }, "authPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/PluginAuthPolicy" - }, - { - "type": "null" - } - ] + "$ref": "#/definitions/PluginAuthPolicy" } }, "required": [ - "appsNeedingAuth" + "appsNeedingAuth", + "authPolicy" ], "title": "PluginInstallResponse", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index 39a0b659c6..cbd7e04c2e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 1ee627988c..e469d38621 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 62ba337558..1e171c604d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index b422c6e52a..c189d7c721 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index b68b46aad0..62fab4677c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 8ad8989d0c..b9747e686f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index bb80ffcc4d..b00af0ef8a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 593e08534a..f314b0c0ef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e0027cb262..47cb32232d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 7798b0c496..f6af6d0a06 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index f2b3ecde1d..8e2f0794c8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 155d8acc47..23c68efaff 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 62d4939330..e61a77748a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index da830143f7..6e77709834 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 5122a03015..9b92196a7c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 77c4cd1a96..27cc358ad8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts index 34753c8e08..1ec1835a61 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts @@ -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. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index a6ac24efcd..125b4b1f1c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -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 | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index b1045a9f9d..5ab15a069b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -16,4 +16,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array, }; +export type PluginInstallResponse = { authPolicy: PluginAuthPolicy, appsNeedingAuth: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index 358914cae7..1eb443c592 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 1132376478..1e0d2aedea 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -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, * 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. */ diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 26cf97011c..067dcb0369 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -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)] diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index bb3c486ee0..c7fa75c106 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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(), ]), }), diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index a28215bbd9..cf84d5bd09 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -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::>(); + 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![ diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index b46da59adc..3ae9953237 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -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>, } @@ -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, - pub model_provider: Option, - pub profile: Option, - pub cwd: Option, - pub approval_policy: Option, - pub sandbox: Option, - pub config: Option>, - pub base_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub developer_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub compact_prompt: Option, - pub include_apply_patch_tool: Option, -} - -#[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, - 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>, - 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>, - 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, - pub cursor: Option, - pub model_providers: Option>, -} - #[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, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ListConversationsResponse { - pub items: Vec, - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ResumeConversationParams { - pub path: Option, - pub conversation_id: Option, - pub history: Option>, - pub overrides: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ForkConversationParams { - pub path: Option, - pub conversation_id: Option, - pub overrides: Option, -} - -#[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, } -#[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, } -#[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, -} - -#[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, - pub reasoning_effort: Option, -} - -#[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, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct SendUserMessageParams { - pub conversation_id: ThreadId, - pub items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct SendUserTurnParams { - pub conversation_id: ThreadId, - pub items: Vec, - 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>, - pub effort: Option, - pub summary: ReasoningSummary, - /// Optional JSON Schema used to constrain the final assistant message for - /// this turn. - pub output_schema: Option, -} - -#[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(¶ms).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, - }, - 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 for V1ByteRange { - fn from(value: CoreByteRange) -> Self { - Self { - start: value.start, - end: value.end, - } - } -} - -impl From 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, -} - -impl From 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 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, - } - } -} diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 60c36a2d54..0c11d2ce8a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3089,8 +3089,8 @@ pub struct PluginSummary { pub source: PluginSource, pub installed: bool, pub enabled: bool, - pub install_policy: Option, - pub auth_policy: Option, + pub install_policy: PluginInstallPolicy, + pub auth_policy: PluginAuthPolicy, pub interface: Option, } @@ -3151,7 +3151,7 @@ pub struct PluginInstallParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PluginInstallResponse { - pub auth_policy: Option, + pub auth_policy: PluginAuthPolicy, pub apps_needing_auth: Vec, } @@ -4013,6 +4013,10 @@ pub enum ThreadItem { receiver_thread_ids: Vec, /// Prompt text sent as part of the collab tool call, when available. prompt: Option, + /// Model requested for the spawned agent, when applicable. + model: Option, + /// Reasoning effort requested for the spawned agent, when applicable. + reasoning_effort: Option, /// Last known status of the target agents, when available. agents_states: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 14ca0cff53..3547bc5f10 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -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", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 24f5155c1c..a69dfab968 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -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 } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1fec3a35db..338a7a7142 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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) diff --git a/codex-rs/app-server/src/app_server_tracing.rs b/codex-rs/app-server/src/app_server_tracing.rs index fd32be87c1..d1555e28eb 100644 --- a/codex-rs/app-server/src/app_server_tracing.rs +++ b/codex-rs/app-server/src/app_server_tracing.rs @@ -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, diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 0ee4683e37..fd9b13c968 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f988b827e6..3b4b6003c6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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>>>, fuzzy_search_sessions: Arc>>, + background_tasks: TaskTracker, feedback: CodexFeedback, log_db: Option, } @@ -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, + 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 { + self.outgoing.request_trace_context(request_id).await + } + + async fn submit_core_op( + &self, + request_id: &ConnectionRequestId, + thread: &CodexThread, + op: Op, + ) -> CodexResult { + 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, experimental_raw_events: bool, + request_trace: Option, ) { 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) -> 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", diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 4e3572ee5d..6110fb52a5 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -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 = diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 887c2c650c..ffd8eecbf7 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -103,6 +103,8 @@ enum OutboundControlEvent { Opened { connection_id: ConnectionId, writer: mpsc::Sender, + // Allow codex/event/* notifications to be emitted. + allow_legacy_notifications: bool, disconnect_sender: Option, initialized: Arc, experimental_api_enabled: Arc, @@ -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)"); } }); diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 973a504310..3b85cc77cc 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -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::(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::(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( + outgoing: Arc, + request_context: RequestContext, + request_fut: F, + ) where + F: Future, + { + outgoing + .register_request_context(request_context.clone()) + .await; + request_fut.instrument(request_context.span()).await; + } + pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { 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; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs new file mode 100644 index 0000000000..622aa40230 --- /dev/null +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -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 = 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> = OnceLock::new(); + GUARD.get_or_init(|| tokio::sync::Mutex::new(())) +} + +struct TracingHarness { + _server: MockServer, + _codex_home: TempDir, + processor: MessageProcessor, + outgoing_rx: mpsc::Receiver, + session: ConnectionSessionState, + tracing: &'static TestTracing, +} + +impl TracingHarness { + async fn new() -> Result { + 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(&mut self, request: ClientRequest, trace: Option) -> 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, + ) -> 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 { + 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, +) -> ( + MessageProcessor, + mpsc::Receiver, +) { + 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::>() + .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( + outgoing_rx: &mut mpsc::Receiver, + 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, +) { + 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(tracing: &TestTracing, predicate: F) -> Vec +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(()) +} diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d615e51518..baf4b65a08 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -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, +} + +impl RequestContext { + pub(crate) fn new( + request_id: ConnectionRequestId, + span: Span, + parent_trace: Option, + ) -> Self { + Self { + request_id, + span, + parent_trace, + } + } + + pub(crate) fn request_trace(&self) -> Option { + 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, request_id_to_callback: Mutex>, + /// 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>, } #[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 { + 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 { + 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, + 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, + 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::(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::(4); @@ -775,6 +907,40 @@ mod tests { } } + #[tokio::test] + async fn connection_closed_clears_registered_request_contexts() { + let (tx, _rx) = mpsc::channel::(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::(4); diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index f537d8ba55..d0aa753358 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -166,6 +166,7 @@ pub(crate) enum TransportEvent { ConnectionOpened { connection_id: ConnectionId, writer: mpsc::Sender, + allow_legacy_notifications: bool, disconnect_sender: Option, }, ConnectionClosed { @@ -203,6 +204,7 @@ pub(crate) struct OutboundConnectionState { pub(crate) initialized: Arc, pub(crate) experimental_api_enabled: Arc, pub(crate) opted_out_notification_methods: Arc>>, + pub(crate) allow_legacy_notifications: bool, pub(crate) writer: mpsc::Sender, disconnect_sender: Option, } @@ -213,12 +215,14 @@ impl OutboundConnectionState { initialized: Arc, experimental_api_enabled: Arc, opted_out_notification_methods: Arc>>, + allow_legacy_notifications: bool, disconnect_sender: Option, ) -> 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) )); } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 2398cd8fc1..f84b70ba0b 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -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(()) diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index c0dc140c4e..ecd897eb84 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -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::() - .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 { - 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 { + 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))) } diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 8887e87f52..6b5bf11564 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -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()]), }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index a784b34908..584cd1a52b 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -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?"; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 2a76f6addb..6b9ed72c0d 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -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(), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index dfbd88e08f..0a5cc8936e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -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(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 3cccc197d2..059bb84a72 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -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::(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::(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::(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::(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::(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??; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 8fd1bb6e50..559be8b18c 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -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??; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 61a8a9794c..5d1b3cc22e 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -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??; diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 823b63cad2..17a6f97ad3 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -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 } diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index dfc05fe317..54e2590c65 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -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, - #[serde(alias = "nextToken")] - next_token: Option, -} - -#[derive(Debug, Deserialize, Clone)] -struct DirectoryApp { - id: String, - name: String, - description: Option, - #[serde(alias = "appMetadata")] - app_metadata: Option, - branding: Option, - labels: Option>, - #[serde(alias = "logoUrl")] - logo_url: Option, - #[serde(alias = "logoUrlDark")] - logo_url_dark: Option, - #[serde(alias = "distributionChannel")] - distribution_channel: Option, - visibility: Option, -} - const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); -#[derive(Clone, PartialEq, Eq)] -struct AllConnectorsCacheKey { - chatgpt_base_url: String, - account_id: Option, - chatgpt_user_id: Option, - is_workspace_account: bool, -} - -#[derive(Clone)] -struct CachedAllConnectors { - key: AllConnectorsCacheKey, - expires_at: Instant, - connectors: Vec, -} - -static ALL_CONNECTORS_CACHE: LazyLock>> = - 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> { if !apps_enabled(config).await { return Ok(Vec::new()); @@ -117,7 +66,7 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option> } 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::>(); - 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::( + 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> { - 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 { @@ -235,248 +139,10 @@ pub fn merge_connectors_with_accessible( filter_disallowed_connectors(merged) } -async fn list_directory_connectors(config: &Config) -> anyhow::Result> { - let mut apps = Vec::new(); - let mut next_token: Option = 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> { - let response: anyhow::Result = 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) -> Vec { - let mut merged: HashMap = 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 { - 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 { diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 3fd4350826..999a92db6c 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -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, diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index f71d459839..b6174efa3a 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -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, } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d977979b89..a6ea0946b9 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -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 { diff --git a/codex-rs/codex-api/src/requests/responses.rs b/codex-rs/codex-api/src/requests/responses.rs index 9fde868f74..5f3e1ba876 100644 --- a/codex-rs/codex-api/src/requests/responses.rs +++ b/codex-rs/codex-api/src/requests/responses.rs @@ -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 { diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index d01e1d918a..e264d8d0a0 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -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!({ diff --git a/codex-rs/connectors/BUILD.bazel b/codex-rs/connectors/BUILD.bazel new file mode 100644 index 0000000000..c4cb9ebde8 --- /dev/null +++ b/codex-rs/connectors/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "connectors", + crate_name = "codex_connectors", +) diff --git a/codex-rs/connectors/Cargo.toml b/codex-rs/connectors/Cargo.toml new file mode 100644 index 0000000000..9cd2428a71 --- /dev/null +++ b/codex-rs/connectors/Cargo.toml @@ -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"] } diff --git a/codex-rs/connectors/src/lib.rs b/codex-rs/connectors/src/lib.rs new file mode 100644 index 0000000000..1d3a729233 --- /dev/null +++ b/codex-rs/connectors/src/lib.rs @@ -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, + chatgpt_user_id: Option, + is_workspace_account: bool, +} + +impl AllConnectorsCacheKey { + pub fn new( + chatgpt_base_url: String, + account_id: Option, + chatgpt_user_id: Option, + 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, +} + +static ALL_CONNECTORS_CACHE: LazyLock>> = + LazyLock::new(|| StdMutex::new(None)); + +#[derive(Debug, Deserialize)] +pub struct DirectoryListResponse { + apps: Vec, + #[serde(alias = "nextToken")] + next_token: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct DirectoryApp { + id: String, + name: String, + description: Option, + #[serde(alias = "appMetadata")] + app_metadata: Option, + branding: Option, + labels: Option>, + #[serde(alias = "logoUrl")] + logo_url: Option, + #[serde(alias = "logoUrlDark")] + logo_url_dark: Option, + #[serde(alias = "distributionChannel")] + distribution_channel: Option, + visibility: Option, +} + +pub fn cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option> { + 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( + cache_key: AllConnectorsCacheKey, + is_workspace_account: bool, + force_refetch: bool, + mut fetch_page: F, +) -> anyhow::Result> +where + F: FnMut(String) -> Fut, + Fut: Future>, +{ + 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::>(); + 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(fetch_page: &mut F) -> anyhow::Result> +where + F: FnMut(String) -> Fut, + Fut: Future>, +{ + let mut apps = Vec::new(); + let mut next_token: Option = 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(fetch_page: &mut F) -> anyhow::Result> +where + F: FnMut(String) -> Fut, + Fut: Future>, +{ + 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) -> Vec { + let mut merged: HashMap = 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 { + 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(()) + } +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index e406586524..ef6b8a0132 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -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 } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ea96b12301..6e2258eaf9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 38f2cf73cb..c9ac18a026 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -487,1097 +487,5 @@ impl AgentControl { } } #[cfg(test)] -mod tests { - use super::*; - use crate::CodexAuth; - use crate::CodexThread; - use crate::ThreadManager; - use crate::agent::agent_status_from_event; - use crate::config::AgentRoleConfig; - use crate::config::Config; - use crate::config::ConfigBuilder; - use crate::config_loader::LoaderOverrides; - use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG; - use crate::features::Feature; - use assert_matches::assert_matches; - use codex_protocol::config_types::ModeKind; - use codex_protocol::models::ContentItem; - use codex_protocol::models::ResponseItem; - use codex_protocol::protocol::ErrorEvent; - use codex_protocol::protocol::EventMsg; - use codex_protocol::protocol::SessionSource; - use codex_protocol::protocol::SubAgentSource; - use codex_protocol::protocol::TurnAbortReason; - use codex_protocol::protocol::TurnAbortedEvent; - use codex_protocol::protocol::TurnCompleteEvent; - use codex_protocol::protocol::TurnStartedEvent; - use pretty_assertions::assert_eq; - use tempfile::TempDir; - use tokio::time::Duration; - use tokio::time::sleep; - use tokio::time::timeout; - use toml::Value as TomlValue; - - async fn test_config_with_cli_overrides( - cli_overrides: Vec<(String, TomlValue)>, - ) -> (TempDir, Config) { - let home = TempDir::new().expect("create temp dir"); - let config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .cli_overrides(cli_overrides) - .loader_overrides(LoaderOverrides { - #[cfg(target_os = "macos")] - managed_preferences_base64: Some(String::new()), - macos_managed_config_requirements_base64: Some(String::new()), - ..LoaderOverrides::default() - }) - .build() - .await - .expect("load default test config"); - (home, config) - } - - async fn test_config() -> (TempDir, Config) { - test_config_with_cli_overrides(Vec::new()).await - } - - fn text_input(text: &str) -> Vec { - vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }] - } - - struct AgentControlHarness { - _home: TempDir, - config: Config, - manager: ThreadManager, - control: AgentControl, - } - - impl AgentControlHarness { - async fn new() -> Self { - let (home, config) = test_config().await; - let manager = ThreadManager::with_models_provider_and_home_for_tests( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let control = manager.agent_control(); - Self { - _home: home, - config, - manager, - control, - } - } - - async fn start_thread(&self) -> (ThreadId, Arc) { - let new_thread = self - .manager - .start_thread(self.config.clone()) - .await - .expect("start thread"); - (new_thread.thread_id, new_thread.thread) - } - } - - fn has_subagent_notification(history_items: &[ResponseItem]) -> bool { - history_items.iter().any(|item| { - let ResponseItem::Message { role, content, .. } = item else { - return false; - }; - if role != "user" { - return false; - } - content.iter().any(|content_item| match content_item { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - text.contains(SUBAGENT_NOTIFICATION_OPEN_TAG) - } - ContentItem::InputImage { .. } => false, - }) - }) - } - - /// Returns true when any message item contains `needle` in a text span. - fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool { - history_items.iter().any(|item| { - let ResponseItem::Message { content, .. } = item else { - return false; - }; - content.iter().any(|content_item| match content_item { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - text.contains(needle) - } - ContentItem::InputImage { .. } => false, - }) - }) - } - - async fn wait_for_subagent_notification(parent_thread: &Arc) -> bool { - let wait = async { - loop { - let history_items = parent_thread - .codex - .session - .clone_history() - .await - .raw_items() - .to_vec(); - if has_subagent_notification(&history_items) { - return true; - } - sleep(Duration::from_millis(25)).await; - } - }; - timeout(Duration::from_secs(2), wait).await.is_ok() - } - - #[tokio::test] - async fn send_input_errors_when_manager_dropped() { - let control = AgentControl::default(); - let err = control - .send_input( - ThreadId::new(), - vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }], - ) - .await - .expect_err("send_input should fail without a manager"); - assert_eq!( - err.to_string(), - "unsupported operation: thread manager dropped" - ); - } - - #[tokio::test] - async fn get_status_returns_not_found_without_manager() { - let control = AgentControl::default(); - let got = control.get_status(ThreadId::new()).await; - assert_eq!(got, AgentStatus::NotFound); - } - - #[tokio::test] - async fn on_event_updates_status_from_task_started() { - let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - })); - assert_eq!(status, Some(AgentStatus::Running)); - } - - #[tokio::test] - async fn on_event_updates_status_from_task_complete() { - let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("done".to_string()), - })); - let expected = AgentStatus::Completed(Some("done".to_string())); - assert_eq!(status, Some(expected)); - } - - #[tokio::test] - async fn on_event_updates_status_from_error() { - let status = agent_status_from_event(&EventMsg::Error(ErrorEvent { - message: "boom".to_string(), - codex_error_info: None, - })); - - let expected = AgentStatus::Errored("boom".to_string()); - assert_eq!(status, Some(expected)); - } - - #[tokio::test] - async fn on_event_updates_status_from_turn_aborted() { - let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - })); - - let expected = AgentStatus::Errored("Interrupted".to_string()); - assert_eq!(status, Some(expected)); - } - - #[tokio::test] - async fn on_event_updates_status_from_shutdown_complete() { - let status = agent_status_from_event(&EventMsg::ShutdownComplete); - assert_eq!(status, Some(AgentStatus::Shutdown)); - } - - #[tokio::test] - async fn spawn_agent_errors_when_manager_dropped() { - let control = AgentControl::default(); - let (_home, config) = test_config().await; - let err = control - .spawn_agent(config, text_input("hello"), None) - .await - .expect_err("spawn_agent should fail without a manager"); - assert_eq!( - err.to_string(), - "unsupported operation: thread manager dropped" - ); - } - - #[tokio::test] - async fn resume_agent_errors_when_manager_dropped() { - let control = AgentControl::default(); - let (_home, config) = test_config().await; - let err = control - .resume_agent_from_rollout(config, ThreadId::new(), SessionSource::Exec) - .await - .expect_err("resume_agent should fail without a manager"); - assert_eq!( - err.to_string(), - "unsupported operation: thread manager dropped" - ); - } - - #[tokio::test] - async fn send_input_errors_when_thread_missing() { - let harness = AgentControlHarness::new().await; - let thread_id = ThreadId::new(); - let err = harness - .control - .send_input( - thread_id, - vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }], - ) - .await - .expect_err("send_input should fail for missing thread"); - assert_matches!(err, CodexErr::ThreadNotFound(id) if id == thread_id); - } - - #[tokio::test] - async fn get_status_returns_not_found_for_missing_thread() { - let harness = AgentControlHarness::new().await; - let status = harness.control.get_status(ThreadId::new()).await; - assert_eq!(status, AgentStatus::NotFound); - } - - #[tokio::test] - async fn get_status_returns_pending_init_for_new_thread() { - let harness = AgentControlHarness::new().await; - let (thread_id, _) = harness.start_thread().await; - let status = harness.control.get_status(thread_id).await; - assert_eq!(status, AgentStatus::PendingInit); - } - - #[tokio::test] - async fn subscribe_status_errors_for_missing_thread() { - let harness = AgentControlHarness::new().await; - let thread_id = ThreadId::new(); - let err = harness - .control - .subscribe_status(thread_id) - .await - .expect_err("subscribe_status should fail for missing thread"); - assert_matches!(err, CodexErr::ThreadNotFound(id) if id == thread_id); - } - - #[tokio::test] - async fn subscribe_status_updates_on_shutdown() { - let harness = AgentControlHarness::new().await; - let (thread_id, thread) = harness.start_thread().await; - let mut status_rx = harness - .control - .subscribe_status(thread_id) - .await - .expect("subscribe_status should succeed"); - assert_eq!(status_rx.borrow().clone(), AgentStatus::PendingInit); - - let _ = thread - .submit(Op::Shutdown {}) - .await - .expect("shutdown should submit"); - - let _ = status_rx.changed().await; - assert_eq!(status_rx.borrow().clone(), AgentStatus::Shutdown); - } - - #[tokio::test] - async fn send_input_submits_user_message() { - let harness = AgentControlHarness::new().await; - let (thread_id, _thread) = harness.start_thread().await; - - let submission_id = harness - .control - .send_input( - thread_id, - vec![UserInput::Text { - text: "hello from tests".to_string(), - text_elements: Vec::new(), - }], - ) - .await - .expect("send_input should succeed"); - assert!(!submission_id.is_empty()); - let expected = ( - thread_id, - Op::UserInput { - items: vec![UserInput::Text { - text: "hello from tests".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - }, - ); - let captured = harness - .manager - .captured_ops() - .into_iter() - .find(|entry| *entry == expected); - assert_eq!(captured, Some(expected)); - } - - #[tokio::test] - async fn spawn_agent_creates_thread_and_sends_prompt() { - let harness = AgentControlHarness::new().await; - let thread_id = harness - .control - .spawn_agent(harness.config.clone(), text_input("spawned"), None) - .await - .expect("spawn_agent should succeed"); - let _thread = harness - .manager - .get_thread(thread_id) - .await - .expect("thread should be registered"); - let expected = ( - thread_id, - Op::UserInput { - items: vec![UserInput::Text { - text: "spawned".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - }, - ); - let captured = harness - .manager - .captured_ops() - .into_iter() - .find(|entry| *entry == expected); - assert_eq!(captured, Some(expected)); - } - - #[tokio::test] - async fn spawn_agent_can_fork_parent_thread_history() { - let harness = AgentControlHarness::new().await; - let (parent_thread_id, parent_thread) = harness.start_thread().await; - parent_thread - .inject_user_message_without_turn("parent seed context".to_string()) - .await; - let turn_context = parent_thread.codex.session.new_default_turn().await; - let parent_spawn_call_id = "spawn-call-history".to_string(); - let parent_spawn_call = ResponseItem::FunctionCall { - id: None, - name: "spawn_agent".to_string(), - arguments: "{}".to_string(), - call_id: parent_spawn_call_id.clone(), - }; - parent_thread - .codex - .session - .record_conversation_items(turn_context.as_ref(), &[parent_spawn_call]) - .await; - parent_thread - .codex - .session - .ensure_rollout_materialized() - .await; - parent_thread.codex.session.flush_rollout().await; - - let child_thread_id = harness - .control - .spawn_agent_with_options( - harness.config.clone(), - text_input("child task"), - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_nickname: None, - agent_role: None, - })), - SpawnAgentOptions { - fork_parent_spawn_call_id: Some(parent_spawn_call_id), - }, - ) - .await - .expect("forked spawn should succeed"); - - let child_thread = harness - .manager - .get_thread(child_thread_id) - .await - .expect("child thread should be registered"); - assert_ne!(child_thread_id, parent_thread_id); - let history = child_thread.codex.session.clone_history().await; - assert!(history_contains_text( - history.raw_items(), - "parent seed context" - )); - - let expected = ( - child_thread_id, - Op::UserInput { - items: vec![UserInput::Text { - text: "child task".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - }, - ); - let captured = harness - .manager - .captured_ops() - .into_iter() - .find(|entry| *entry == expected); - assert_eq!(captured, Some(expected)); - - let _ = harness - .control - .shutdown_agent(child_thread_id) - .await - .expect("child shutdown should submit"); - let _ = parent_thread - .submit(Op::Shutdown {}) - .await - .expect("parent shutdown should submit"); - } - - #[tokio::test] - async fn spawn_agent_fork_injects_output_for_parent_spawn_call() { - let harness = AgentControlHarness::new().await; - let (parent_thread_id, parent_thread) = harness.start_thread().await; - let turn_context = parent_thread.codex.session.new_default_turn().await; - let parent_spawn_call_id = "spawn-call-1".to_string(); - let parent_spawn_call = ResponseItem::FunctionCall { - id: None, - name: "spawn_agent".to_string(), - arguments: "{}".to_string(), - call_id: parent_spawn_call_id.clone(), - }; - parent_thread - .codex - .session - .record_conversation_items(turn_context.as_ref(), &[parent_spawn_call]) - .await; - parent_thread - .codex - .session - .ensure_rollout_materialized() - .await; - parent_thread.codex.session.flush_rollout().await; - - let child_thread_id = harness - .control - .spawn_agent_with_options( - harness.config.clone(), - text_input("child task"), - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_nickname: None, - agent_role: None, - })), - SpawnAgentOptions { - fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), - }, - ) - .await - .expect("forked spawn should succeed"); - - let child_thread = harness - .manager - .get_thread(child_thread_id) - .await - .expect("child thread should be registered"); - let history = child_thread.codex.session.clone_history().await; - let injected_output = history.raw_items().iter().find_map(|item| match item { - ResponseItem::FunctionCallOutput { call_id, output } - if call_id == &parent_spawn_call_id => - { - Some(output) - } - _ => None, - }); - let injected_output = - injected_output.expect("forked child should contain synthetic tool output"); - assert_eq!( - injected_output.text_content(), - Some(FORKED_SPAWN_AGENT_OUTPUT_MESSAGE) - ); - assert_eq!(injected_output.success, Some(true)); - - let _ = harness - .control - .shutdown_agent(child_thread_id) - .await - .expect("child shutdown should submit"); - let _ = parent_thread - .submit(Op::Shutdown {}) - .await - .expect("parent shutdown should submit"); - } - - #[tokio::test] - async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { - let harness = AgentControlHarness::new().await; - let (parent_thread_id, parent_thread) = harness.start_thread().await; - let turn_context = parent_thread.codex.session.new_default_turn().await; - let parent_spawn_call_id = "spawn-call-unflushed".to_string(); - let parent_spawn_call = ResponseItem::FunctionCall { - id: None, - name: "spawn_agent".to_string(), - arguments: "{}".to_string(), - call_id: parent_spawn_call_id.clone(), - }; - parent_thread - .codex - .session - .record_conversation_items(turn_context.as_ref(), &[parent_spawn_call]) - .await; - - let child_thread_id = harness - .control - .spawn_agent_with_options( - harness.config.clone(), - text_input("child task"), - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_nickname: None, - agent_role: None, - })), - SpawnAgentOptions { - fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), - }, - ) - .await - .expect("forked spawn should flush parent rollout before loading history"); - - let child_thread = harness - .manager - .get_thread(child_thread_id) - .await - .expect("child thread should be registered"); - let history = child_thread.codex.session.clone_history().await; - - let mut parent_call_index = None; - let mut injected_output_index = None; - for (idx, item) in history.raw_items().iter().enumerate() { - match item { - ResponseItem::FunctionCall { call_id, .. } if call_id == &parent_spawn_call_id => { - parent_call_index = Some(idx); - } - ResponseItem::FunctionCallOutput { call_id, .. } - if call_id == &parent_spawn_call_id => - { - injected_output_index = Some(idx); - } - _ => {} - } - } - - let parent_call_index = - parent_call_index.expect("forked child should include the parent spawn_agent call"); - let injected_output_index = injected_output_index - .expect("forked child should include synthetic output for the parent spawn_agent call"); - assert!(parent_call_index < injected_output_index); - - let _ = harness - .control - .shutdown_agent(child_thread_id) - .await - .expect("child shutdown should submit"); - let _ = parent_thread - .submit(Op::Shutdown {}) - .await - .expect("parent shutdown should submit"); - } - - #[tokio::test] - async fn spawn_agent_respects_max_threads_limit() { - let max_threads = 1usize; - let (_home, config) = test_config_with_cli_overrides(vec![( - "agents.max_threads".to_string(), - TomlValue::Integer(max_threads as i64), - )]) - .await; - let manager = ThreadManager::with_models_provider_and_home_for_tests( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let control = manager.agent_control(); - - let _ = manager - .start_thread(config.clone()) - .await - .expect("start thread"); - - let first_agent_id = control - .spawn_agent(config.clone(), text_input("hello"), None) - .await - .expect("spawn_agent should succeed"); - - let err = control - .spawn_agent(config, text_input("hello again"), None) - .await - .expect_err("spawn_agent should respect max threads"); - let CodexErr::AgentLimitReached { - max_threads: seen_max_threads, - } = err - else { - panic!("expected CodexErr::AgentLimitReached"); - }; - assert_eq!(seen_max_threads, max_threads); - - let _ = control - .shutdown_agent(first_agent_id) - .await - .expect("shutdown agent"); - } - - #[tokio::test] - async fn spawn_agent_releases_slot_after_shutdown() { - let max_threads = 1usize; - let (_home, config) = test_config_with_cli_overrides(vec![( - "agents.max_threads".to_string(), - TomlValue::Integer(max_threads as i64), - )]) - .await; - let manager = ThreadManager::with_models_provider_and_home_for_tests( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let control = manager.agent_control(); - - let first_agent_id = control - .spawn_agent(config.clone(), text_input("hello"), None) - .await - .expect("spawn_agent should succeed"); - let _ = control - .shutdown_agent(first_agent_id) - .await - .expect("shutdown agent"); - - let second_agent_id = control - .spawn_agent(config.clone(), text_input("hello again"), None) - .await - .expect("spawn_agent should succeed after shutdown"); - let _ = control - .shutdown_agent(second_agent_id) - .await - .expect("shutdown agent"); - } - - #[tokio::test] - async fn spawn_agent_limit_shared_across_clones() { - let max_threads = 1usize; - let (_home, config) = test_config_with_cli_overrides(vec![( - "agents.max_threads".to_string(), - TomlValue::Integer(max_threads as i64), - )]) - .await; - let manager = ThreadManager::with_models_provider_and_home_for_tests( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let control = manager.agent_control(); - let cloned = control.clone(); - - let first_agent_id = cloned - .spawn_agent(config.clone(), text_input("hello"), None) - .await - .expect("spawn_agent should succeed"); - - let err = control - .spawn_agent(config, text_input("hello again"), None) - .await - .expect_err("spawn_agent should respect shared guard"); - let CodexErr::AgentLimitReached { max_threads } = err else { - panic!("expected CodexErr::AgentLimitReached"); - }; - assert_eq!(max_threads, 1); - - let _ = control - .shutdown_agent(first_agent_id) - .await - .expect("shutdown agent"); - } - - #[tokio::test] - async fn resume_agent_respects_max_threads_limit() { - let max_threads = 1usize; - let (_home, config) = test_config_with_cli_overrides(vec![( - "agents.max_threads".to_string(), - TomlValue::Integer(max_threads as i64), - )]) - .await; - let manager = ThreadManager::with_models_provider_and_home_for_tests( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let control = manager.agent_control(); - - let resumable_id = control - .spawn_agent(config.clone(), text_input("hello"), None) - .await - .expect("spawn_agent should succeed"); - let _ = control - .shutdown_agent(resumable_id) - .await - .expect("shutdown resumable thread"); - - let active_id = control - .spawn_agent(config.clone(), text_input("occupy"), None) - .await - .expect("spawn_agent should succeed for active slot"); - - let err = control - .resume_agent_from_rollout(config, resumable_id, SessionSource::Exec) - .await - .expect_err("resume should respect max threads"); - let CodexErr::AgentLimitReached { - max_threads: seen_max_threads, - } = err - else { - panic!("expected CodexErr::AgentLimitReached"); - }; - assert_eq!(seen_max_threads, max_threads); - - let _ = control - .shutdown_agent(active_id) - .await - .expect("shutdown active thread"); - } - - #[tokio::test] - async fn resume_agent_releases_slot_after_resume_failure() { - let max_threads = 1usize; - let (_home, config) = test_config_with_cli_overrides(vec![( - "agents.max_threads".to_string(), - TomlValue::Integer(max_threads as i64), - )]) - .await; - let manager = ThreadManager::with_models_provider_and_home_for_tests( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let control = manager.agent_control(); - - let _ = control - .resume_agent_from_rollout(config.clone(), ThreadId::new(), SessionSource::Exec) - .await - .expect_err("resume should fail for missing rollout path"); - - let resumed_id = control - .spawn_agent(config, text_input("hello"), None) - .await - .expect("spawn should succeed after failed resume"); - let _ = control - .shutdown_agent(resumed_id) - .await - .expect("shutdown resumed thread"); - } - - #[tokio::test] - async fn spawn_child_completion_notifies_parent_history() { - let harness = AgentControlHarness::new().await; - let (parent_thread_id, parent_thread) = harness.start_thread().await; - - let child_thread_id = harness - .control - .spawn_agent( - harness.config.clone(), - text_input("hello child"), - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_nickname: None, - agent_role: Some("explorer".to_string()), - })), - ) - .await - .expect("child spawn should succeed"); - - let child_thread = harness - .manager - .get_thread(child_thread_id) - .await - .expect("child thread should exist"); - let _ = child_thread - .submit(Op::Shutdown {}) - .await - .expect("child shutdown should submit"); - - assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); - } - - #[tokio::test] - async fn completion_watcher_notifies_parent_when_child_is_missing() { - let harness = AgentControlHarness::new().await; - let (parent_thread_id, parent_thread) = harness.start_thread().await; - let child_thread_id = ThreadId::new(); - - harness.control.maybe_start_completion_watcher( - child_thread_id, - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_nickname: None, - agent_role: Some("explorer".to_string()), - })), - ); - - assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); - - let history_items = parent_thread - .codex - .session - .clone_history() - .await - .raw_items() - .to_vec(); - assert_eq!( - history_contains_text( - &history_items, - &format!("\"agent_id\":\"{child_thread_id}\"") - ), - true - ); - assert_eq!( - history_contains_text(&history_items, "\"status\":\"not_found\""), - true - ); - } - - #[tokio::test] - async fn spawn_thread_subagent_gets_random_nickname_in_session_source() { - let harness = AgentControlHarness::new().await; - let (parent_thread_id, _parent_thread) = harness.start_thread().await; - - let child_thread_id = harness - .control - .spawn_agent( - harness.config.clone(), - text_input("hello child"), - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_nickname: None, - agent_role: Some("explorer".to_string()), - })), - ) - .await - .expect("child spawn should succeed"); - - let child_thread = harness - .manager - .get_thread(child_thread_id) - .await - .expect("child thread should be registered"); - let snapshot = child_thread.config_snapshot().await; - - let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: seen_parent_thread_id, - depth, - agent_nickname, - agent_role, - }) = snapshot.session_source - else { - panic!("expected thread-spawn sub-agent source"); - }; - assert_eq!(seen_parent_thread_id, parent_thread_id); - assert_eq!(depth, 1); - assert!(agent_nickname.is_some()); - assert_eq!(agent_role, Some("explorer".to_string())); - } - - #[tokio::test] - async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { - let mut harness = AgentControlHarness::new().await; - harness.config.agent_roles.insert( - "researcher".to_string(), - AgentRoleConfig { - description: Some("Research role".to_string()), - config_file: None, - nickname_candidates: Some(vec!["Atlas".to_string()]), - }, - ); - let (parent_thread_id, _parent_thread) = harness.start_thread().await; - - let child_thread_id = harness - .control - .spawn_agent( - harness.config.clone(), - text_input("hello child"), - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_nickname: None, - agent_role: Some("researcher".to_string()), - })), - ) - .await - .expect("child spawn should succeed"); - - let child_thread = harness - .manager - .get_thread(child_thread_id) - .await - .expect("child thread should be registered"); - let snapshot = child_thread.config_snapshot().await; - - let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) = - snapshot.session_source - else { - panic!("expected thread-spawn sub-agent source"); - }; - assert_eq!(agent_nickname, Some("Atlas".to_string())); - } - - #[tokio::test] - async fn resume_thread_subagent_restores_stored_nickname_and_role() { - let (home, mut config) = test_config().await; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); - let manager = ThreadManager::with_models_provider_and_home_for_tests( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let control = manager.agent_control(); - let harness = AgentControlHarness { - _home: home, - config, - manager, - control, - }; - let (parent_thread_id, _parent_thread) = harness.start_thread().await; - - let child_thread_id = harness - .control - .spawn_agent( - harness.config.clone(), - text_input("hello child"), - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_nickname: None, - agent_role: Some("explorer".to_string()), - })), - ) - .await - .expect("child spawn should succeed"); - - let child_thread = harness - .manager - .get_thread(child_thread_id) - .await - .expect("child thread should exist"); - let mut status_rx = harness - .control - .subscribe_status(child_thread_id) - .await - .expect("status subscription should succeed"); - if matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { - timeout(Duration::from_secs(5), async { - loop { - status_rx - .changed() - .await - .expect("child status should advance past pending init"); - if !matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { - break; - } - } - }) - .await - .expect("child should initialize before shutdown"); - } - let original_snapshot = child_thread.config_snapshot().await; - let original_nickname = original_snapshot - .session_source - .get_nickname() - .expect("spawned sub-agent should have a nickname"); - let state_db = child_thread - .state_db() - .expect("sqlite state db should be available for nickname resume test"); - timeout(Duration::from_secs(5), async { - loop { - if let Ok(Some(metadata)) = state_db.get_thread(child_thread_id).await - && metadata.agent_nickname.is_some() - && metadata.agent_role.as_deref() == Some("explorer") - { - break; - } - sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("child thread metadata should be persisted to sqlite before shutdown"); - - let _ = harness - .control - .shutdown_agent(child_thread_id) - .await - .expect("child shutdown should submit"); - - let resumed_thread_id = harness - .control - .resume_agent_from_rollout( - harness.config.clone(), - child_thread_id, - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_nickname: None, - agent_role: None, - }), - ) - .await - .expect("resume should succeed"); - assert_eq!(resumed_thread_id, child_thread_id); - - let resumed_snapshot = harness - .manager - .get_thread(resumed_thread_id) - .await - .expect("resumed child thread should exist") - .config_snapshot() - .await; - let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: resumed_parent_thread_id, - depth: resumed_depth, - agent_nickname: resumed_nickname, - agent_role: resumed_role, - }) = resumed_snapshot.session_source - else { - panic!("expected thread-spawn sub-agent source"); - }; - assert_eq!(resumed_parent_thread_id, parent_thread_id); - assert_eq!(resumed_depth, 1); - assert_eq!(resumed_nickname, Some(original_nickname)); - assert_eq!(resumed_role, Some("explorer".to_string())); - - let _ = harness - .control - .shutdown_agent(resumed_thread_id) - .await - .expect("resumed child shutdown should submit"); - } -} +#[path = "control_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs new file mode 100644 index 0000000000..d78c448b29 --- /dev/null +++ b/codex-rs/core/src/agent/control_tests.rs @@ -0,0 +1,1095 @@ +use super::*; +use crate::CodexAuth; +use crate::CodexThread; +use crate::ThreadManager; +use crate::agent::agent_status_from_event; +use crate::config::AgentRoleConfig; +use crate::config::Config; +use crate::config::ConfigBuilder; +use crate::config_loader::LoaderOverrides; +use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG; +use crate::features::Feature; +use assert_matches::assert_matches; +use codex_protocol::config_types::ModeKind; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::ErrorEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnStartedEvent; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::sleep; +use tokio::time::timeout; +use toml::Value as TomlValue; + +async fn test_config_with_cli_overrides( + cli_overrides: Vec<(String, TomlValue)>, +) -> (TempDir, Config) { + let home = TempDir::new().expect("create temp dir"); + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .cli_overrides(cli_overrides) + .loader_overrides(LoaderOverrides { + #[cfg(target_os = "macos")] + managed_preferences_base64: Some(String::new()), + macos_managed_config_requirements_base64: Some(String::new()), + ..LoaderOverrides::default() + }) + .build() + .await + .expect("load default test config"); + (home, config) +} + +async fn test_config() -> (TempDir, Config) { + test_config_with_cli_overrides(Vec::new()).await +} + +fn text_input(text: &str) -> Vec { + vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }] +} + +struct AgentControlHarness { + _home: TempDir, + config: Config, + manager: ThreadManager, + control: AgentControl, +} + +impl AgentControlHarness { + async fn new() -> Self { + let (home, config) = test_config().await; + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + Self { + _home: home, + config, + manager, + control, + } + } + + async fn start_thread(&self) -> (ThreadId, Arc) { + let new_thread = self + .manager + .start_thread(self.config.clone()) + .await + .expect("start thread"); + (new_thread.thread_id, new_thread.thread) + } +} + +fn has_subagent_notification(history_items: &[ResponseItem]) -> bool { + history_items.iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "user" { + return false; + } + content.iter().any(|content_item| match content_item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + text.contains(SUBAGENT_NOTIFICATION_OPEN_TAG) + } + ContentItem::InputImage { .. } => false, + }) + }) +} + +/// Returns true when any message item contains `needle` in a text span. +fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool { + history_items.iter().any(|item| { + let ResponseItem::Message { content, .. } = item else { + return false; + }; + content.iter().any(|content_item| match content_item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + text.contains(needle) + } + ContentItem::InputImage { .. } => false, + }) + }) +} + +async fn wait_for_subagent_notification(parent_thread: &Arc) -> bool { + let wait = async { + loop { + let history_items = parent_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + if has_subagent_notification(&history_items) { + return true; + } + sleep(Duration::from_millis(25)).await; + } + }; + timeout(Duration::from_secs(2), wait).await.is_ok() +} + +#[tokio::test] +async fn send_input_errors_when_manager_dropped() { + let control = AgentControl::default(); + let err = control + .send_input( + ThreadId::new(), + vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ) + .await + .expect_err("send_input should fail without a manager"); + assert_eq!( + err.to_string(), + "unsupported operation: thread manager dropped" + ); +} + +#[tokio::test] +async fn get_status_returns_not_found_without_manager() { + let control = AgentControl::default(); + let got = control.get_status(ThreadId::new()).await; + assert_eq!(got, AgentStatus::NotFound); +} + +#[tokio::test] +async fn on_event_updates_status_from_task_started() { + let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + })); + assert_eq!(status, Some(AgentStatus::Running)); +} + +#[tokio::test] +async fn on_event_updates_status_from_task_complete() { + let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("done".to_string()), + })); + let expected = AgentStatus::Completed(Some("done".to_string())); + assert_eq!(status, Some(expected)); +} + +#[tokio::test] +async fn on_event_updates_status_from_error() { + let status = agent_status_from_event(&EventMsg::Error(ErrorEvent { + message: "boom".to_string(), + codex_error_info: None, + })); + + let expected = AgentStatus::Errored("boom".to_string()); + assert_eq!(status, Some(expected)); +} + +#[tokio::test] +async fn on_event_updates_status_from_turn_aborted() { + let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + })); + + let expected = AgentStatus::Errored("Interrupted".to_string()); + assert_eq!(status, Some(expected)); +} + +#[tokio::test] +async fn on_event_updates_status_from_shutdown_complete() { + let status = agent_status_from_event(&EventMsg::ShutdownComplete); + assert_eq!(status, Some(AgentStatus::Shutdown)); +} + +#[tokio::test] +async fn spawn_agent_errors_when_manager_dropped() { + let control = AgentControl::default(); + let (_home, config) = test_config().await; + let err = control + .spawn_agent(config, text_input("hello"), None) + .await + .expect_err("spawn_agent should fail without a manager"); + assert_eq!( + err.to_string(), + "unsupported operation: thread manager dropped" + ); +} + +#[tokio::test] +async fn resume_agent_errors_when_manager_dropped() { + let control = AgentControl::default(); + let (_home, config) = test_config().await; + let err = control + .resume_agent_from_rollout(config, ThreadId::new(), SessionSource::Exec) + .await + .expect_err("resume_agent should fail without a manager"); + assert_eq!( + err.to_string(), + "unsupported operation: thread manager dropped" + ); +} + +#[tokio::test] +async fn send_input_errors_when_thread_missing() { + let harness = AgentControlHarness::new().await; + let thread_id = ThreadId::new(); + let err = harness + .control + .send_input( + thread_id, + vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ) + .await + .expect_err("send_input should fail for missing thread"); + assert_matches!(err, CodexErr::ThreadNotFound(id) if id == thread_id); +} + +#[tokio::test] +async fn get_status_returns_not_found_for_missing_thread() { + let harness = AgentControlHarness::new().await; + let status = harness.control.get_status(ThreadId::new()).await; + assert_eq!(status, AgentStatus::NotFound); +} + +#[tokio::test] +async fn get_status_returns_pending_init_for_new_thread() { + let harness = AgentControlHarness::new().await; + let (thread_id, _) = harness.start_thread().await; + let status = harness.control.get_status(thread_id).await; + assert_eq!(status, AgentStatus::PendingInit); +} + +#[tokio::test] +async fn subscribe_status_errors_for_missing_thread() { + let harness = AgentControlHarness::new().await; + let thread_id = ThreadId::new(); + let err = harness + .control + .subscribe_status(thread_id) + .await + .expect_err("subscribe_status should fail for missing thread"); + assert_matches!(err, CodexErr::ThreadNotFound(id) if id == thread_id); +} + +#[tokio::test] +async fn subscribe_status_updates_on_shutdown() { + let harness = AgentControlHarness::new().await; + let (thread_id, thread) = harness.start_thread().await; + let mut status_rx = harness + .control + .subscribe_status(thread_id) + .await + .expect("subscribe_status should succeed"); + assert_eq!(status_rx.borrow().clone(), AgentStatus::PendingInit); + + let _ = thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); + + let _ = status_rx.changed().await; + assert_eq!(status_rx.borrow().clone(), AgentStatus::Shutdown); +} + +#[tokio::test] +async fn send_input_submits_user_message() { + let harness = AgentControlHarness::new().await; + let (thread_id, _thread) = harness.start_thread().await; + + let submission_id = harness + .control + .send_input( + thread_id, + vec![UserInput::Text { + text: "hello from tests".to_string(), + text_elements: Vec::new(), + }], + ) + .await + .expect("send_input should succeed"); + assert!(!submission_id.is_empty()); + let expected = ( + thread_id, + Op::UserInput { + items: vec![UserInput::Text { + text: "hello from tests".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); +} + +#[tokio::test] +async fn spawn_agent_creates_thread_and_sends_prompt() { + let harness = AgentControlHarness::new().await; + let thread_id = harness + .control + .spawn_agent(harness.config.clone(), text_input("spawned"), None) + .await + .expect("spawn_agent should succeed"); + let _thread = harness + .manager + .get_thread(thread_id) + .await + .expect("thread should be registered"); + let expected = ( + thread_id, + Op::UserInput { + items: vec![UserInput::Text { + text: "spawned".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); +} + +#[tokio::test] +async fn spawn_agent_can_fork_parent_thread_history() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + parent_thread + .inject_user_message_without_turn("parent seed context".to_string()) + .await; + let turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-history".to_string(); + let parent_spawn_call = ResponseItem::FunctionCall { + id: None, + name: "spawn_agent".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: parent_spawn_call_id.clone(), + }; + parent_thread + .codex + .session + .record_conversation_items(turn_context.as_ref(), &[parent_spawn_call]) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread.codex.session.flush_rollout().await; + + let child_thread_id = harness + .control + .spawn_agent_with_options( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id), + }, + ) + .await + .expect("forked spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + assert_ne!(child_thread_id, parent_thread_id); + let history = child_thread.codex.session.clone_history().await; + assert!(history_contains_text( + history.raw_items(), + "parent seed context" + )); + + let expected = ( + child_thread_id, + Op::UserInput { + items: vec![UserInput::Text { + text: "child task".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); + + let _ = harness + .control + .shutdown_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +#[tokio::test] +async fn spawn_agent_fork_injects_output_for_parent_spawn_call() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + let turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-1".to_string(); + let parent_spawn_call = ResponseItem::FunctionCall { + id: None, + name: "spawn_agent".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: parent_spawn_call_id.clone(), + }; + parent_thread + .codex + .session + .record_conversation_items(turn_context.as_ref(), &[parent_spawn_call]) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread.codex.session.flush_rollout().await; + + let child_thread_id = harness + .control + .spawn_agent_with_options( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), + }, + ) + .await + .expect("forked spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + let injected_output = history.raw_items().iter().find_map(|item| match item { + ResponseItem::FunctionCallOutput { call_id, output } + if call_id == &parent_spawn_call_id => + { + Some(output) + } + _ => None, + }); + let injected_output = + injected_output.expect("forked child should contain synthetic tool output"); + assert_eq!( + injected_output.text_content(), + Some(FORKED_SPAWN_AGENT_OUTPUT_MESSAGE) + ); + assert_eq!(injected_output.success, Some(true)); + + let _ = harness + .control + .shutdown_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +#[tokio::test] +async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + let turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-unflushed".to_string(); + let parent_spawn_call = ResponseItem::FunctionCall { + id: None, + name: "spawn_agent".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: parent_spawn_call_id.clone(), + }; + parent_thread + .codex + .session + .record_conversation_items(turn_context.as_ref(), &[parent_spawn_call]) + .await; + + let child_thread_id = harness + .control + .spawn_agent_with_options( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), + }, + ) + .await + .expect("forked spawn should flush parent rollout before loading history"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + + let mut parent_call_index = None; + let mut injected_output_index = None; + for (idx, item) in history.raw_items().iter().enumerate() { + match item { + ResponseItem::FunctionCall { call_id, .. } if call_id == &parent_spawn_call_id => { + parent_call_index = Some(idx); + } + ResponseItem::FunctionCallOutput { call_id, .. } + if call_id == &parent_spawn_call_id => + { + injected_output_index = Some(idx); + } + _ => {} + } + } + + let parent_call_index = + parent_call_index.expect("forked child should include the parent spawn_agent call"); + let injected_output_index = injected_output_index + .expect("forked child should include synthetic output for the parent spawn_agent call"); + assert!(parent_call_index < injected_output_index); + + let _ = harness + .control + .shutdown_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +#[tokio::test] +async fn spawn_agent_respects_max_threads_limit() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + + let _ = manager + .start_thread(config.clone()) + .await + .expect("start thread"); + + let first_agent_id = control + .spawn_agent(config.clone(), text_input("hello"), None) + .await + .expect("spawn_agent should succeed"); + + let err = control + .spawn_agent(config, text_input("hello again"), None) + .await + .expect_err("spawn_agent should respect max threads"); + let CodexErr::AgentLimitReached { + max_threads: seen_max_threads, + } = err + else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(seen_max_threads, max_threads); + + let _ = control + .shutdown_agent(first_agent_id) + .await + .expect("shutdown agent"); +} + +#[tokio::test] +async fn spawn_agent_releases_slot_after_shutdown() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + + let first_agent_id = control + .spawn_agent(config.clone(), text_input("hello"), None) + .await + .expect("spawn_agent should succeed"); + let _ = control + .shutdown_agent(first_agent_id) + .await + .expect("shutdown agent"); + + let second_agent_id = control + .spawn_agent(config.clone(), text_input("hello again"), None) + .await + .expect("spawn_agent should succeed after shutdown"); + let _ = control + .shutdown_agent(second_agent_id) + .await + .expect("shutdown agent"); +} + +#[tokio::test] +async fn spawn_agent_limit_shared_across_clones() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + let cloned = control.clone(); + + let first_agent_id = cloned + .spawn_agent(config.clone(), text_input("hello"), None) + .await + .expect("spawn_agent should succeed"); + + let err = control + .spawn_agent(config, text_input("hello again"), None) + .await + .expect_err("spawn_agent should respect shared guard"); + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + let _ = control + .shutdown_agent(first_agent_id) + .await + .expect("shutdown agent"); +} + +#[tokio::test] +async fn resume_agent_respects_max_threads_limit() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + + let resumable_id = control + .spawn_agent(config.clone(), text_input("hello"), None) + .await + .expect("spawn_agent should succeed"); + let _ = control + .shutdown_agent(resumable_id) + .await + .expect("shutdown resumable thread"); + + let active_id = control + .spawn_agent(config.clone(), text_input("occupy"), None) + .await + .expect("spawn_agent should succeed for active slot"); + + let err = control + .resume_agent_from_rollout(config, resumable_id, SessionSource::Exec) + .await + .expect_err("resume should respect max threads"); + let CodexErr::AgentLimitReached { + max_threads: seen_max_threads, + } = err + else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(seen_max_threads, max_threads); + + let _ = control + .shutdown_agent(active_id) + .await + .expect("shutdown active thread"); +} + +#[tokio::test] +async fn resume_agent_releases_slot_after_resume_failure() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + + let _ = control + .resume_agent_from_rollout(config.clone(), ThreadId::new(), SessionSource::Exec) + .await + .expect_err("resume should fail for missing rollout path"); + + let resumed_id = control + .spawn_agent(config, text_input("hello"), None) + .await + .expect("spawn should succeed after failed resume"); + let _ = control + .shutdown_agent(resumed_id) + .await + .expect("shutdown resumed thread"); +} + +#[tokio::test] +async fn spawn_child_completion_notifies_parent_history() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let _ = child_thread + .submit(Op::Shutdown {}) + .await + .expect("child shutdown should submit"); + + assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); +} + +#[tokio::test] +async fn completion_watcher_notifies_parent_when_child_is_missing() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + let child_thread_id = ThreadId::new(); + + harness.control.maybe_start_completion_watcher( + child_thread_id, + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ); + + assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); + + let history_items = parent_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert_eq!( + history_contains_text( + &history_items, + &format!("\"agent_id\":\"{child_thread_id}\"") + ), + true + ); + assert_eq!( + history_contains_text(&history_items, "\"status\":\"not_found\""), + true + ); +} + +#[tokio::test] +async fn spawn_thread_subagent_gets_random_nickname_in_session_source() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let snapshot = child_thread.config_snapshot().await; + + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: seen_parent_thread_id, + depth, + agent_nickname, + agent_role, + }) = snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(seen_parent_thread_id, parent_thread_id); + assert_eq!(depth, 1); + assert!(agent_nickname.is_some()); + assert_eq!(agent_role, Some("explorer".to_string())); +} + +#[tokio::test] +async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { + let mut harness = AgentControlHarness::new().await; + harness.config.agent_roles.insert( + "researcher".to_string(), + AgentRoleConfig { + description: Some("Research role".to_string()), + config_file: None, + nickname_candidates: Some(vec!["Atlas".to_string()]), + }, + ); + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: Some("researcher".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let snapshot = child_thread.config_snapshot().await; + + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) = + snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(agent_nickname, Some("Atlas".to_string())); +} + +#[tokio::test] +async fn resume_thread_subagent_restores_stored_nickname_and_role() { + let (home, mut config) = test_config().await; + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + let harness = AgentControlHarness { + _home: home, + config, + manager, + control, + }; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let mut status_rx = harness + .control + .subscribe_status(child_thread_id) + .await + .expect("status subscription should succeed"); + if matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { + timeout(Duration::from_secs(5), async { + loop { + status_rx + .changed() + .await + .expect("child status should advance past pending init"); + if !matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { + break; + } + } + }) + .await + .expect("child should initialize before shutdown"); + } + let original_snapshot = child_thread.config_snapshot().await; + let original_nickname = original_snapshot + .session_source + .get_nickname() + .expect("spawned sub-agent should have a nickname"); + let state_db = child_thread + .state_db() + .expect("sqlite state db should be available for nickname resume test"); + timeout(Duration::from_secs(5), async { + loop { + if let Ok(Some(metadata)) = state_db.get_thread(child_thread_id).await + && metadata.agent_nickname.is_some() + && metadata.agent_role.as_deref() == Some("explorer") + { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("child thread metadata should be persisted to sqlite before shutdown"); + + let _ = harness + .control + .shutdown_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + + let resumed_thread_id = harness + .control + .resume_agent_from_rollout( + harness.config.clone(), + child_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: None, + }), + ) + .await + .expect("resume should succeed"); + assert_eq!(resumed_thread_id, child_thread_id); + + let resumed_snapshot = harness + .manager + .get_thread(resumed_thread_id) + .await + .expect("resumed child thread should exist") + .config_snapshot() + .await; + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: resumed_parent_thread_id, + depth: resumed_depth, + agent_nickname: resumed_nickname, + agent_role: resumed_role, + }) = resumed_snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(resumed_parent_thread_id, parent_thread_id); + assert_eq!(resumed_depth, 1); + assert_eq!(resumed_nickname, Some(original_nickname)); + assert_eq!(resumed_role, Some("explorer".to_string())); + + let _ = harness + .control + .shutdown_agent(resumed_thread_id) + .await + .expect("resumed child shutdown should submit"); +} diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/guards.rs index 056d2b7f6a..167b993013 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/guards.rs @@ -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; diff --git a/codex-rs/core/src/agent/guards_tests.rs b/codex-rs/core/src/agent/guards_tests.rs new file mode 100644 index 0000000000..53bb5f3b30 --- /dev/null +++ b/codex-rs/core/src/agent/guards_tests.rs @@ -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); +} diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 23b60583e7..8d607c5436 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -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; diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs new file mode 100644 index 0000000000..cb04aa4e8f --- /dev/null +++ b/codex-rs/core/src/agent/role_tests.rs @@ -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 + ); +} diff --git a/codex-rs/core/src/analytics_client.rs b/codex-rs/core/src/analytics_client.rs index c8829eda00..f2df7e010d 100644 --- a/codex-rs/core/src/analytics_client.rs +++ b/codex-rs/core/src/analytics_client.rs @@ -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; diff --git a/codex-rs/core/src/analytics_client_tests.rs b/codex-rs/core/src/analytics_client_tests.rs new file mode 100644 index 0000000000..66e9a1234b --- /dev/null +++ b/codex-rs/core/src/analytics_client_tests.rs @@ -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); +} diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/core/src/api_bridge.rs index 3b2024f58b..f363201ae9 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/core/src/api_bridge.rs @@ -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 { extract_request_id(headers).or_else(|| extract_header(headers, CF_RAY_HEADER)) diff --git a/codex-rs/core/src/api_bridge_tests.rs b/codex-rs/core/src/api_bridge_tests.rs new file mode 100644 index 0000000000..e8391021b1 --- /dev/null +++ b/codex-rs/core/src/api_bridge_tests.rs @@ -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 + ); +} diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 1928a95621..9a09ae9f09 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -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; diff --git a/codex-rs/core/src/apply_patch_tests.rs b/codex-rs/core/src/apply_patch_tests.rs new file mode 100644 index 0000000000..1b9e722d5a --- /dev/null +++ b/codex-rs/core/src/apply_patch_tests.rs @@ -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() + }) + ); +} diff --git a/codex-rs/core/src/arc_monitor.rs b/codex-rs/core/src/arc_monitor.rs index 6727ae01bf..c704faafc0 100644 --- a/codex-rs/core/src/arc_monitor.rs +++ b/codex-rs/core/src/arc_monitor.rs @@ -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, - } - - 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( - "\n/tmp\n" - .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; diff --git a/codex-rs/core/src/arc_monitor_tests.rs b/codex-rs/core/src/arc_monitor_tests.rs new file mode 100644 index 0000000000..cd0cb80b14 --- /dev/null +++ b/codex-rs/core/src/arc_monitor_tests.rs @@ -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, +} + +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( + "\n/tmp\n".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); +} diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 9f13cdf2b5..8bb2b23d87 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -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, - chatgpt_plan_type: Option, - chatgpt_account_id: Option, - } - - fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { - 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, - forced_chatgpt_workspace_id: Option, - ) -> 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, - } - - #[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; diff --git a/codex-rs/core/src/auth/storage.rs b/codex-rs/core/src/auth/storage.rs index 81d17e4e1e..b1e04b8685 100644 --- a/codex-rs/core/src/auth/storage.rs +++ b/codex-rs/core/src/auth/storage.rs @@ -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( - mock_keyring: &MockKeyringStore, - codex_home: &Path, - compute_key: F, - ) -> anyhow::Result<(String, PathBuf)> - where - F: FnOnce() -> std::io::Result, - { - 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( - mock_keyring: &MockKeyringStore, - compute_key: F, - auth: &AuthDotJson, - ) -> anyhow::Result<()> - where - F: FnOnce() -> std::io::Result, - { - 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; diff --git a/codex-rs/core/src/auth/storage_tests.rs b/codex-rs/core/src/auth/storage_tests.rs new file mode 100644 index 0000000000..4bf72c11b9 --- /dev/null +++ b/codex-rs/core/src/auth/storage_tests.rs @@ -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( + mock_keyring: &MockKeyringStore, + codex_home: &Path, + compute_key: F, +) -> anyhow::Result<(String, PathBuf)> +where + F: FnOnce() -> std::io::Result, +{ + 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( + mock_keyring: &MockKeyringStore, + compute_key: F, + auth: &AuthDotJson, +) -> anyhow::Result<()> +where + F: FnOnce() -> std::io::Result, +{ + 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(()) +} diff --git a/codex-rs/core/src/auth_tests.rs b/codex-rs/core/src/auth_tests.rs new file mode 100644 index 0000000000..0c4a574f34 --- /dev/null +++ b/codex-rs/core/src/auth_tests.rs @@ -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, + chatgpt_plan_type: Option, + chatgpt_account_id: Option, +} + +fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { + 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, + forced_chatgpt_workspace_id: Option, +) -> 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, +} + +#[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)); +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index dcecad6b79..47d01d4a44 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -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; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 08613f0eab..e88e1af124 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -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, pub(crate) parameters: JsonSchema, #[serde(skip)] pub(crate) output_schema: Option, } + + #[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, + } + + #[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 = vec![]; - let tools: Vec = 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 = vec![]; - let tools: Vec = 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 = vec![]; - let tools: Vec = 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; diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs new file mode 100644 index 0000000000..769defabbc --- /dev/null +++ b/codex-rs/core/src/client_common_tests.rs @@ -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 = vec![]; + let tools: Vec = 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 = vec![]; + let tools: Vec = 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 = vec![]; + let tools: Vec = 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": {} + } + } + ] + }) + ); +} diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs new file mode 100644 index 0000000000..138b61ffbb --- /dev/null +++ b/codex-rs/core/src/client_tests.rs @@ -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); +} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d71fc9fe1d..ef5a02e8fc 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -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, pub(crate) session: Arc, + // 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>; + /// 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, + pub(crate) models_manager: Arc, + pub(crate) skills_manager: Arc, + pub(crate) plugins_manager: Arc, + pub(crate) mcp_manager: Arc, + pub(crate) file_watcher: Arc, + pub(crate) conversation_history: InitialHistory, + pub(crate) session_source: SessionSource, + pub(crate) agent_control: AgentControl, + pub(crate) dynamic_tools: Vec, + pub(crate) persist_extended_history: bool, + pub(crate) metrics_service_name: Option, + pub(crate) inherited_shell_snapshot: Option>, + pub(crate) parent_trace: Option, +} + 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, - models_manager: Arc, - skills_manager: Arc, - plugins_manager: Arc, - mcp_manager: Arc, - file_watcher: Arc, - conversation_history: InitialHistory, - session_source: SessionSource, - agent_control: AgentControl, - dynamic_tools: Vec, - persist_extended_history: bool, - metrics_service_name: Option, - inherited_shell_snapshot: Option>, - ) -> CodexResult { + pub(crate) async fn spawn(args: CodexSpawnArgs) -> CodexResult { + 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 { + 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 { + self.submit_with_trace(op, None).await + } + + pub async fn submit_with_trace( + &self, + op: Op, + trace: Option, + ) -> CodexResult { 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 { 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 = mcp_servers .iter() @@ -1882,26 +1967,6 @@ impl Session { } } - pub(crate) async fn merge_mcp_tool_selection(&self, tool_names: Vec) -> Vec { - 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) { - let mut state = self.state.lock().await; - state.set_mcp_tool_selection(tool_names); - } - - pub(crate) async fn get_mcp_tool_selection(&self) -> Option> { - 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> { - let mut search_call_ids = HashSet::new(); - let mut active_selected_tools: Option> = 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::(&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::>>() - else { - continue; - }; - active_selected_tools = Some(selected_tools); - } - _ => {} - } - } - - active_selected_tools - } - async fn previous_turn_settings(&self) -> Option { 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, + ) -> 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(), + }, ))) } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index f353febd04..91a6fb2d61 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -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; diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs new file mode 100644 index 0000000000..3d4c49c9df --- /dev/null +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -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, + } + ); +} diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 305ee1f426..6861844855 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -168,9 +168,12 @@ fn default_image_save_developer_message_text() -> String { fn test_tool_runtime(session: Arc, turn_context: Arc) -> 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 = 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::>(); 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 = 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::>(); 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 = 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, ) -> ( @@ -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", diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 6bed9fd37b..b0a16fc022 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -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"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index b33a66c3ed..7a243f9f74 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::UserInput; use std::path::PathBuf; use tokio::sync::Mutex; @@ -67,6 +68,18 @@ impl CodexThread { self.codex.submit(op).await } + pub async fn shutdown_and_wait(&self) -> CodexResult<()> { + self.codex.shutdown_and_wait().await + } + + pub async fn submit_with_trace( + &self, + op: Op, + trace: Option, + ) -> CodexResult { + self.codex.submit_with_trace(op, trace).await + } + pub async fn steer_input( &self, input: Vec, diff --git a/codex-rs/core/src/command_canonicalization.rs b/codex-rs/core/src/command_canonicalization.rs index 0708e41e19..3457fa2f63 100644 --- a/codex-rs/core/src/command_canonicalization.rs +++ b/codex-rs/core/src/command_canonicalization.rs @@ -38,93 +38,5 @@ pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec) -> Option } #[cfg(test)] -mod tests { - use super::build_commit_message_trailer; - use super::commit_message_trailer_instruction; - use super::resolve_attribution_value; - - #[test] - fn blank_attribution_disables_trailer_prompt() { - assert_eq!(build_commit_message_trailer(Some("")), None); - assert_eq!(commit_message_trailer_instruction(Some(" ")), None); - } - - #[test] - fn default_attribution_uses_codex_trailer() { - assert_eq!( - build_commit_message_trailer(None).as_deref(), - Some("Co-authored-by: Codex ") - ); - } - - #[test] - fn resolve_value_handles_default_custom_and_blank() { - assert_eq!( - resolve_attribution_value(None), - Some("Codex ".to_string()) - ); - assert_eq!( - resolve_attribution_value(Some("MyAgent ")), - Some("MyAgent ".to_string()) - ); - assert_eq!( - resolve_attribution_value(Some("MyAgent")), - Some("MyAgent".to_string()) - ); - assert_eq!(resolve_attribution_value(Some(" ")), None); - } - - #[test] - fn instruction_mentions_trailer_and_omits_generated_with() { - let instruction = commit_message_trailer_instruction(Some("AgentX ")) - .expect("instruction expected"); - assert!(instruction.contains("Co-authored-by: AgentX ")); - assert!(instruction.contains("exactly once")); - assert!(!instruction.contains("Generated-with")); - } -} +#[path = "commit_attribution_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/commit_attribution_tests.rs b/codex-rs/core/src/commit_attribution_tests.rs new file mode 100644 index 0000000000..be7661a604 --- /dev/null +++ b/codex-rs/core/src/commit_attribution_tests.rs @@ -0,0 +1,43 @@ +use super::build_commit_message_trailer; +use super::commit_message_trailer_instruction; +use super::resolve_attribution_value; + +#[test] +fn blank_attribution_disables_trailer_prompt() { + assert_eq!(build_commit_message_trailer(Some("")), None); + assert_eq!(commit_message_trailer_instruction(Some(" ")), None); +} + +#[test] +fn default_attribution_uses_codex_trailer() { + assert_eq!( + build_commit_message_trailer(None).as_deref(), + Some("Co-authored-by: Codex ") + ); +} + +#[test] +fn resolve_value_handles_default_custom_and_blank() { + assert_eq!( + resolve_attribution_value(None), + Some("Codex ".to_string()) + ); + assert_eq!( + resolve_attribution_value(Some("MyAgent ")), + Some("MyAgent ".to_string()) + ); + assert_eq!( + resolve_attribution_value(Some("MyAgent")), + Some("MyAgent".to_string()) + ); + assert_eq!(resolve_attribution_value(Some(" ")), None); +} + +#[test] +fn instruction_mentions_trailer_and_omits_generated_with() { + let instruction = commit_message_trailer_instruction(Some("AgentX ")) + .expect("instruction expected"); + assert!(instruction.contains("Co-authored-by: AgentX ")); + assert!(instruction.contains("exactly once")); + assert!(!instruction.contains("Generated-with")); +} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index d20cee6132..282558390e 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -440,606 +440,5 @@ async fn drain_to_completed( } #[cfg(test)] -mod tests { - - use super::*; - use pretty_assertions::assert_eq; - - async fn process_compacted_history_with_test_session( - compacted_history: Vec, - previous_turn_settings: Option<&PreviousTurnSettings>, - ) -> (Vec, Vec) { - let (session, turn_context) = crate::codex::make_session_and_context().await; - session - .set_previous_turn_settings(previous_turn_settings.cloned()) - .await; - let initial_context = session.build_initial_context(&turn_context).await; - let refreshed = crate::compact_remote::process_compacted_history( - &session, - &turn_context, - compacted_history, - InitialContextInjection::BeforeLastUserMessage, - ) - .await; - (refreshed, initial_context) - } - - #[test] - fn content_items_to_text_joins_non_empty_segments() { - let items = vec![ - ContentItem::InputText { - text: "hello".to_string(), - }, - ContentItem::OutputText { - text: String::new(), - }, - ContentItem::OutputText { - text: "world".to_string(), - }, - ]; - - let joined = content_items_to_text(&items); - - assert_eq!(Some("hello\nworld".to_string()), joined); - } - - #[test] - fn content_items_to_text_ignores_image_only_content() { - let items = vec![ContentItem::InputImage { - image_url: "file://image.png".to_string(), - }]; - - let joined = content_items_to_text(&items); - - assert_eq!(None, joined); - } - - #[test] - fn collect_user_messages_extracts_user_text_only() { - let items = vec![ - ResponseItem::Message { - id: Some("assistant".to_string()), - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "ignored".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: Some("user".to_string()), - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "first".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Other, - ]; - - let collected = collect_user_messages(&items); - - assert_eq!(vec!["first".to_string()], collected); - } - - #[test] - fn collect_user_messages_filters_session_prefix_entries() { - let items = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#"# AGENTS.md instructions for project - - -do things -"# - .to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "cwd=/tmp".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "real user message".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ]; - - let collected = collect_user_messages(&items); - - assert_eq!(vec!["real user message".to_string()], collected); - } - - #[test] - fn build_token_limited_compacted_history_truncates_overlong_user_messages() { - // Use a small truncation limit so the test remains fast while still validating - // that oversized user content is truncated. - let max_tokens = 16; - let big = "word ".repeat(200); - let history = super::build_compacted_history_with_limit( - Vec::new(), - std::slice::from_ref(&big), - "SUMMARY", - max_tokens, - ); - assert_eq!(history.len(), 2); - - let truncated_message = &history[0]; - let summary_message = &history[1]; - - let truncated_text = match truncated_message { - ResponseItem::Message { role, content, .. } if role == "user" => { - content_items_to_text(content).unwrap_or_default() - } - other => panic!("unexpected item in history: {other:?}"), - }; - - assert!( - truncated_text.contains("tokens truncated"), - "expected truncation marker in truncated user message" - ); - assert!( - !truncated_text.contains(&big), - "truncated user message should not include the full oversized user text" - ); - - let summary_text = match summary_message { - ResponseItem::Message { role, content, .. } if role == "user" => { - content_items_to_text(content).unwrap_or_default() - } - other => panic!("unexpected item in history: {other:?}"), - }; - assert_eq!(summary_text, "SUMMARY"); - } - - #[test] - fn build_token_limited_compacted_history_appends_summary_message() { - let initial_context: Vec = Vec::new(); - let user_messages = vec!["first user message".to_string()]; - let summary_text = "summary text"; - - let history = build_compacted_history(initial_context, &user_messages, summary_text); - assert!( - !history.is_empty(), - "expected compacted history to include summary" - ); - - let last = history.last().expect("history should have a summary entry"); - let summary = match last { - ResponseItem::Message { role, content, .. } if role == "user" => { - content_items_to_text(content).unwrap_or_default() - } - other => panic!("expected summary message, found {other:?}"), - }; - assert_eq!(summary, summary_text); - } - - #[tokio::test] - async fn process_compacted_history_replaces_developer_messages() { - let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "stale permissions".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "stale personality".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ]; - let (refreshed, mut expected) = - process_compacted_history_with_test_session(compacted_history, None).await; - expected.push(ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }); - assert_eq!(refreshed, expected); - } - - #[tokio::test] - async fn process_compacted_history_reinjects_full_initial_context() { - let compacted_history = vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }]; - let (refreshed, mut expected) = - process_compacted_history_with_test_session(compacted_history, None).await; - expected.push(ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }); - assert_eq!(refreshed, expected); - } - - #[tokio::test] - async fn process_compacted_history_drops_non_user_content_messages() { - let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#"# AGENTS.md instructions for /repo - - -keep me updated -"# - .to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - /repo - zsh -"# - .to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - turn-1 - interrupted -"# - .to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "stale developer instructions".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ]; - let (refreshed, mut expected) = - process_compacted_history_with_test_session(compacted_history, None).await; - expected.push(ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }); - assert_eq!(refreshed, expected); - } - - #[tokio::test] - async fn process_compacted_history_inserts_context_before_last_real_user_message_only() { - let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "older user".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "latest user".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ]; - - let (refreshed, initial_context) = - process_compacted_history_with_test_session(compacted_history, None).await; - let mut expected = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "older user".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ]; - expected.extend(initial_context); - expected.push(ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "latest user".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }); - assert_eq!(refreshed, expected); - } - - #[tokio::test] - async fn process_compacted_history_reinjects_model_switch_message() { - let compacted_history = vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }]; - let previous_turn_settings = PreviousTurnSettings { - model: "previous-regular-model".to_string(), - realtime_active: None, - }; - - let (refreshed, initial_context) = process_compacted_history_with_test_session( - compacted_history, - Some(&previous_turn_settings), - ) - .await; - - let ResponseItem::Message { role, content, .. } = &initial_context[0] else { - panic!("expected developer message"); - }; - assert_eq!(role, "developer"); - let [ContentItem::InputText { text }, ..] = content.as_slice() else { - panic!("expected developer text"); - }; - assert!(text.contains("")); - - let mut expected = initial_context; - expected.push(ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }); - assert_eq!(refreshed, expected); - } - - #[test] - fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() { - let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "older user".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "latest user".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ]; - let initial_context = vec![ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }]; - - let refreshed = insert_initial_context_before_last_real_user_or_summary( - compacted_history, - initial_context, - ); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "older user".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "latest user".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ]; - assert_eq!(refreshed, expected); - } - - #[test] - fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last() { - let compacted_history = vec![ResponseItem::Compaction { - encrypted_content: "encrypted".to_string(), - }]; - let initial_context = vec![ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }]; - - let refreshed = insert_initial_context_before_last_real_user_or_summary( - compacted_history, - initial_context, - ); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Compaction { - encrypted_content: "encrypted".to_string(), - }, - ]; - assert_eq!(refreshed, expected); - } -} +#[path = "compact_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 473d91e549..fad1bd6284 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -202,7 +202,9 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs new file mode 100644 index 0000000000..762fa60020 --- /dev/null +++ b/codex-rs/core/src/compact_tests.rs @@ -0,0 +1,596 @@ +use super::*; +use pretty_assertions::assert_eq; + +async fn process_compacted_history_with_test_session( + compacted_history: Vec, + previous_turn_settings: Option<&PreviousTurnSettings>, +) -> (Vec, Vec) { + let (session, turn_context) = crate::codex::make_session_and_context().await; + session + .set_previous_turn_settings(previous_turn_settings.cloned()) + .await; + let initial_context = session.build_initial_context(&turn_context).await; + let refreshed = crate::compact_remote::process_compacted_history( + &session, + &turn_context, + compacted_history, + InitialContextInjection::BeforeLastUserMessage, + ) + .await; + (refreshed, initial_context) +} + +#[test] +fn content_items_to_text_joins_non_empty_segments() { + let items = vec![ + ContentItem::InputText { + text: "hello".to_string(), + }, + ContentItem::OutputText { + text: String::new(), + }, + ContentItem::OutputText { + text: "world".to_string(), + }, + ]; + + let joined = content_items_to_text(&items); + + assert_eq!(Some("hello\nworld".to_string()), joined); +} + +#[test] +fn content_items_to_text_ignores_image_only_content() { + let items = vec![ContentItem::InputImage { + image_url: "file://image.png".to_string(), + }]; + + let joined = content_items_to_text(&items); + + assert_eq!(None, joined); +} + +#[test] +fn collect_user_messages_extracts_user_text_only() { + let items = vec![ + ResponseItem::Message { + id: Some("assistant".to_string()), + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "ignored".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: Some("user".to_string()), + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "first".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Other, + ]; + + let collected = collect_user_messages(&items); + + assert_eq!(vec!["first".to_string()], collected); +} + +#[test] +fn collect_user_messages_filters_session_prefix_entries() { + let items = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: r#"# AGENTS.md instructions for project + + +do things +"# + .to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "cwd=/tmp".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "real user message".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ]; + + let collected = collect_user_messages(&items); + + assert_eq!(vec!["real user message".to_string()], collected); +} + +#[test] +fn build_token_limited_compacted_history_truncates_overlong_user_messages() { + // Use a small truncation limit so the test remains fast while still validating + // that oversized user content is truncated. + let max_tokens = 16; + let big = "word ".repeat(200); + let history = super::build_compacted_history_with_limit( + Vec::new(), + std::slice::from_ref(&big), + "SUMMARY", + max_tokens, + ); + assert_eq!(history.len(), 2); + + let truncated_message = &history[0]; + let summary_message = &history[1]; + + let truncated_text = match truncated_message { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).unwrap_or_default() + } + other => panic!("unexpected item in history: {other:?}"), + }; + + assert!( + truncated_text.contains("tokens truncated"), + "expected truncation marker in truncated user message" + ); + assert!( + !truncated_text.contains(&big), + "truncated user message should not include the full oversized user text" + ); + + let summary_text = match summary_message { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).unwrap_or_default() + } + other => panic!("unexpected item in history: {other:?}"), + }; + assert_eq!(summary_text, "SUMMARY"); +} + +#[test] +fn build_token_limited_compacted_history_appends_summary_message() { + let initial_context: Vec = Vec::new(); + let user_messages = vec!["first user message".to_string()]; + let summary_text = "summary text"; + + let history = build_compacted_history(initial_context, &user_messages, summary_text); + assert!( + !history.is_empty(), + "expected compacted history to include summary" + ); + + let last = history.last().expect("history should have a summary entry"); + let summary = match last { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).unwrap_or_default() + } + other => panic!("expected summary message, found {other:?}"), + }; + assert_eq!(summary, summary_text); +} + +#[tokio::test] +async fn process_compacted_history_replaces_developer_messages() { + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale permissions".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale personality".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ]; + let (refreshed, mut expected) = + process_compacted_history_with_test_session(compacted_history, None).await; + expected.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }); + assert_eq!(refreshed, expected); +} + +#[tokio::test] +async fn process_compacted_history_reinjects_full_initial_context() { + let compacted_history = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }]; + let (refreshed, mut expected) = + process_compacted_history_with_test_session(compacted_history, None).await; + expected.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }); + assert_eq!(refreshed, expected); +} + +#[tokio::test] +async fn process_compacted_history_drops_non_user_content_messages() { + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: r#"# AGENTS.md instructions for /repo + + +keep me updated +"# + .to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: r#" + /repo + zsh +"# + .to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: r#" + turn-1 + interrupted +"# + .to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale developer instructions".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ]; + let (refreshed, mut expected) = + process_compacted_history_with_test_session(compacted_history, None).await; + expected.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }); + assert_eq!(refreshed, expected); +} + +#[tokio::test] +async fn process_compacted_history_inserts_context_before_last_real_user_message_only() { + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "latest user".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ]; + + let (refreshed, initial_context) = + process_compacted_history_with_test_session(compacted_history, None).await; + let mut expected = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ]; + expected.extend(initial_context); + expected.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "latest user".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }); + assert_eq!(refreshed, expected); +} + +#[tokio::test] +async fn process_compacted_history_reinjects_model_switch_message() { + let compacted_history = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }]; + let previous_turn_settings = PreviousTurnSettings { + model: "previous-regular-model".to_string(), + realtime_active: None, + }; + + let (refreshed, initial_context) = process_compacted_history_with_test_session( + compacted_history, + Some(&previous_turn_settings), + ) + .await; + + let ResponseItem::Message { role, content, .. } = &initial_context[0] else { + panic!("expected developer message"); + }; + assert_eq!(role, "developer"); + let [ContentItem::InputText { text }, ..] = content.as_slice() else { + panic!("expected developer text"); + }; + assert!(text.contains("")); + + let mut expected = initial_context; + expected.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }); + assert_eq!(refreshed, expected); +} + +#[test] +fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() { + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "latest user".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ]; + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }]; + + let refreshed = + insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "latest user".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ]; + assert_eq!(refreshed, expected); +} + +#[test] +fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last() { + let compacted_history = vec![ResponseItem::Compaction { + encrypted_content: "encrypted".to_string(), + }]; + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }]; + + let refreshed = + insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Compaction { + encrypted_content: "encrypted".to_string(), + }, + ]; + assert_eq!(refreshed, expected); +} diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 5549a34144..c6d92e74b9 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -17,7 +17,9 @@ use codex_config::CONFIG_TOML_FILE; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; use serde::Deserialize; use tempfile::tempdir; @@ -1044,6 +1046,76 @@ trust_level = "trusted" } } +#[test] +fn legacy_sandbox_mode_config_builds_split_policies_without_drift() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let extra_root = test_absolute_path("/tmp/legacy-extra-root"); + let cases = vec![ + ( + "danger-full-access".to_string(), + r#"sandbox_mode = "danger-full-access" +"# + .to_string(), + ), + ( + "read-only".to_string(), + r#"sandbox_mode = "read-only" +"# + .to_string(), + ), + ( + "workspace-write".to_string(), + format!( + r#"sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = [{}] +exclude_tmpdir_env_var = true +exclude_slash_tmp = true +"#, + serde_json::json!(extra_root) + ), + ), + ]; + + for (name, config_toml) in cases { + let cfg = toml::from_str::(&config_toml) + .unwrap_or_else(|err| panic!("case `{name}` should parse: {err}")); + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + let sandbox_policy = config.permissions.sandbox_policy.get(); + assert_eq!( + config.permissions.file_system_sandbox_policy, + FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, cwd.path()), + "case `{name}` should preserve filesystem semantics from legacy config" + ); + assert_eq!( + config.permissions.network_sandbox_policy, + NetworkSandboxPolicy::from(sandbox_policy), + "case `{name}` should preserve network semantics from legacy config" + ); + assert_eq!( + config + .permissions + .file_system_sandbox_policy + .to_legacy_sandbox_policy(config.permissions.network_sandbox_policy, cwd.path()) + .unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")), + sandbox_policy.clone(), + "case `{name}` should round-trip through split policies without drift" + ); + } + + Ok(()) +} + #[test] fn filter_mcp_servers_by_allowlist_enforces_identity_rules() { const MISMATCHED_COMMAND_SERVER: &str = "mismatched-command-should-disable"; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index cf139d3a5e..8d0011b5e8 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -952,1016 +952,5 @@ impl ConfigEditsBuilder { } #[cfg(test)] -mod tests { - use super::*; - use crate::config::types::McpServerTransportConfig; - use codex_protocol::openai_models::ReasoningEffort; - use pretty_assertions::assert_eq; - #[cfg(unix)] - use std::os::unix::fs::symlink; - use tempfile::tempdir; - use toml::Value as TomlValue; - - #[test] - fn blocking_set_model_top_level() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - apply_blocking( - codex_home, - None, - &[ConfigEdit::SetModel { - model: Some("gpt-5.1-codex".to_string()), - effort: Some(ReasoningEffort::High), - }], - ) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"model = "gpt-5.1-codex" -model_reasoning_effort = "high" -"#; - assert_eq!(contents, expected); - } - - #[test] - fn builder_with_edits_applies_custom_paths() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - ConfigEditsBuilder::new(codex_home) - .with_edits(vec![ConfigEdit::SetPath { - segments: vec!["enabled".to_string()], - value: value(true), - }]) - .apply_blocking() - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - assert_eq!(contents, "enabled = true\n"); - } - - #[test] - fn set_model_availability_nux_count_writes_shown_count() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - let shown_count = HashMap::from([("gpt-foo".to_string(), 4)]); - - ConfigEditsBuilder::new(codex_home) - .set_model_availability_nux_count(&shown_count) - .apply_blocking() - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[tui.model_availability_nux] -gpt-foo = 4 -"#; - assert_eq!(contents, expected); - } - - #[test] - fn set_skill_config_writes_disabled_entry() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - ConfigEditsBuilder::new(codex_home) - .with_edits([ConfigEdit::SetSkillConfig { - path: PathBuf::from("/tmp/skills/demo/SKILL.md"), - enabled: false, - }]) - .apply_blocking() - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[[skills.config]] -path = "/tmp/skills/demo/SKILL.md" -enabled = false -"#; - assert_eq!(contents, expected); - } - - #[test] - fn set_skill_config_removes_entry_when_enabled() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[[skills.config]] -path = "/tmp/skills/demo/SKILL.md" -enabled = false -"#, - ) - .expect("seed config"); - - ConfigEditsBuilder::new(codex_home) - .with_edits([ConfigEdit::SetSkillConfig { - path: PathBuf::from("/tmp/skills/demo/SKILL.md"), - enabled: true, - }]) - .apply_blocking() - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - assert_eq!(contents, ""); - } - - #[test] - fn blocking_set_model_preserves_inline_table_contents() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - // Seed with inline tables for profiles to simulate common user config. - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"profile = "fast" - -profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } -"#, - ) - .expect("seed"); - - apply_blocking( - codex_home, - None, - &[ConfigEdit::SetModel { - model: Some("o4-mini".to_string()), - effort: None, - }], - ) - .expect("persist"); - - let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let value: TomlValue = toml::from_str(&raw).expect("parse config"); - - // Ensure sandbox_mode is preserved under profiles.fast and model updated. - let profiles_tbl = value - .get("profiles") - .and_then(|v| v.as_table()) - .expect("profiles table"); - let fast_tbl = profiles_tbl - .get("fast") - .and_then(|v| v.as_table()) - .expect("fast table"); - assert_eq!( - fast_tbl.get("sandbox_mode").and_then(|v| v.as_str()), - Some("strict") - ); - assert_eq!( - fast_tbl.get("model").and_then(|v| v.as_str()), - Some("o4-mini") - ); - } - - #[cfg(unix)] - #[test] - fn blocking_set_model_writes_through_symlink_chain() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - let target_dir = tempdir().expect("target dir"); - let target_path = target_dir.path().join(CONFIG_TOML_FILE); - let link_path = codex_home.join("config-link.toml"); - let config_path = codex_home.join(CONFIG_TOML_FILE); - - symlink(&target_path, &link_path).expect("symlink link"); - symlink("config-link.toml", &config_path).expect("symlink config"); - - apply_blocking( - codex_home, - None, - &[ConfigEdit::SetModel { - model: Some("gpt-5.1-codex".to_string()), - effort: Some(ReasoningEffort::High), - }], - ) - .expect("persist"); - - let meta = std::fs::symlink_metadata(&config_path).expect("config metadata"); - assert!(meta.file_type().is_symlink()); - - let contents = std::fs::read_to_string(&target_path).expect("read target"); - let expected = r#"model = "gpt-5.1-codex" -model_reasoning_effort = "high" -"#; - assert_eq!(contents, expected); - } - - #[cfg(unix)] - #[test] - fn blocking_set_model_replaces_symlink_on_cycle() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - let link_a = codex_home.join("a.toml"); - let link_b = codex_home.join("b.toml"); - let config_path = codex_home.join(CONFIG_TOML_FILE); - - symlink("b.toml", &link_a).expect("symlink a"); - symlink("a.toml", &link_b).expect("symlink b"); - symlink("a.toml", &config_path).expect("symlink config"); - - apply_blocking( - codex_home, - None, - &[ConfigEdit::SetModel { - model: Some("gpt-5.1-codex".to_string()), - effort: None, - }], - ) - .expect("persist"); - - let meta = std::fs::symlink_metadata(&config_path).expect("config metadata"); - assert!(!meta.file_type().is_symlink()); - - let contents = std::fs::read_to_string(&config_path).expect("read config"); - let expected = r#"model = "gpt-5.1-codex" -"#; - assert_eq!(contents, expected); - } - - #[test] - fn batch_write_table_upsert_preserves_inline_comments() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - let original = r#"approval_policy = "never" - -[mcp_servers.linear] -name = "linear" -# ok -url = "https://linear.example" - -[mcp_servers.linear.http_headers] -foo = "bar" - -[sandbox_workspace_write] -# ok 3 -network_access = false -"#; - std::fs::write(codex_home.join(CONFIG_TOML_FILE), original).expect("seed config"); - - apply_blocking( - codex_home, - None, - &[ - ConfigEdit::SetPath { - segments: vec![ - "mcp_servers".to_string(), - "linear".to_string(), - "url".to_string(), - ], - value: value("https://linear.example/v2"), - }, - ConfigEdit::SetPath { - segments: vec![ - "sandbox_workspace_write".to_string(), - "network_access".to_string(), - ], - value: value(true), - }, - ], - ) - .expect("apply"); - - let updated = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"approval_policy = "never" - -[mcp_servers.linear] -name = "linear" -# ok -url = "https://linear.example/v2" - -[mcp_servers.linear.http_headers] -foo = "bar" - -[sandbox_workspace_write] -# ok 3 -network_access = true -"#; - assert_eq!(updated, expected); - } - - #[test] - fn blocking_clear_model_removes_inline_table_entry() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"profile = "fast" - -profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } -"#, - ) - .expect("seed"); - - apply_blocking( - codex_home, - None, - &[ConfigEdit::SetModel { - model: None, - effort: Some(ReasoningEffort::High), - }], - ) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"profile = "fast" - -[profiles.fast] -sandbox_mode = "strict" -model_reasoning_effort = "high" -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_set_model_scopes_to_active_profile() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"profile = "team" - -[profiles.team] -model_reasoning_effort = "low" -"#, - ) - .expect("seed"); - - apply_blocking( - codex_home, - None, - &[ConfigEdit::SetModel { - model: Some("o5-preview".to_string()), - effort: Some(ReasoningEffort::Minimal), - }], - ) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"profile = "team" - -[profiles.team] -model_reasoning_effort = "minimal" -model = "o5-preview" -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_set_model_with_explicit_profile() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[profiles."team a"] -model = "gpt-5.1-codex" -"#, - ) - .expect("seed"); - - apply_blocking( - codex_home, - Some("team a"), - &[ConfigEdit::SetModel { - model: Some("o4-mini".to_string()), - effort: None, - }], - ) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[profiles."team a"] -model = "o4-mini" -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_set_hide_full_access_warning_preserves_table() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"# Global comment - -[notice] -# keep me -existing = "value" -"#, - ) - .expect("seed"); - - apply_blocking( - codex_home, - None, - &[ConfigEdit::SetNoticeHideFullAccessWarning(true)], - ) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"# Global comment - -[notice] -# keep me -existing = "value" -hide_full_access_warning = true -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_set_hide_rate_limit_model_nudge_preserves_table() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[notice] -existing = "value" -"#, - ) - .expect("seed"); - - apply_blocking( - codex_home, - None, - &[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)], - ) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[notice] -existing = "value" -hide_rate_limit_model_nudge = true -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_set_hide_gpt5_1_migration_prompt_preserves_table() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[notice] -existing = "value" -"#, - ) - .expect("seed"); - apply_blocking( - codex_home, - None, - &[ConfigEdit::SetNoticeHideModelMigrationPrompt( - "hide_gpt5_1_migration_prompt".to_string(), - true, - )], - ) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[notice] -existing = "value" -hide_gpt5_1_migration_prompt = true -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_set_hide_gpt_5_1_codex_max_migration_prompt_preserves_table() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[notice] -existing = "value" -"#, - ) - .expect("seed"); - apply_blocking( - codex_home, - None, - &[ConfigEdit::SetNoticeHideModelMigrationPrompt( - "hide_gpt-5.1-codex-max_migration_prompt".to_string(), - true, - )], - ) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[notice] -existing = "value" -"hide_gpt-5.1-codex-max_migration_prompt" = true -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_record_model_migration_seen_preserves_table() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[notice] -existing = "value" -"#, - ) - .expect("seed"); - apply_blocking( - codex_home, - None, - &[ConfigEdit::RecordModelMigrationSeen { - from: "gpt-5".to_string(), - to: "gpt-5.1".to_string(), - }], - ) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[notice] -existing = "value" - -[notice.model_migrations] -gpt-5 = "gpt-5.1" -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_replace_mcp_servers_round_trips() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - let mut servers = BTreeMap::new(); - servers.insert( - "stdio".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: "cmd".to_string(), - args: vec!["--flag".to_string()], - env: Some( - [ - ("B".to_string(), "2".to_string()), - ("A".to_string(), "1".to_string()), - ] - .into_iter() - .collect(), - ), - env_vars: vec!["FOO".to_string()], - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: Some(vec!["one".to_string(), "two".to_string()]), - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - ); - - servers.insert( - "http".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://example.com".to_string(), - bearer_token_env_var: Some("TOKEN".to_string()), - http_headers: Some( - [("Z-Header".to_string(), "z".to_string())] - .into_iter() - .collect(), - ), - env_http_headers: None, - }, - enabled: false, - required: false, - disabled_reason: None, - startup_timeout_sec: Some(std::time::Duration::from_secs(5)), - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: Some(vec!["forbidden".to_string()]), - scopes: None, - oauth_resource: Some("https://resource.example.com".to_string()), - }, - ); - - apply_blocking( - codex_home, - None, - &[ConfigEdit::ReplaceMcpServers(servers.clone())], - ) - .expect("persist"); - - let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = "\ -[mcp_servers.http] -url = \"https://example.com\" -bearer_token_env_var = \"TOKEN\" -enabled = false -startup_timeout_sec = 5.0 -disabled_tools = [\"forbidden\"] -oauth_resource = \"https://resource.example.com\" - -[mcp_servers.http.http_headers] -Z-Header = \"z\" - -[mcp_servers.stdio] -command = \"cmd\" -args = [\"--flag\"] -env_vars = [\"FOO\"] -enabled_tools = [\"one\", \"two\"] - -[mcp_servers.stdio.env] -A = \"1\" -B = \"2\" -"; - assert_eq!(raw, expected); - } - - #[test] - fn blocking_replace_mcp_servers_preserves_inline_comments() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[mcp_servers] -# keep me -foo = { command = "cmd" } -"#, - ) - .expect("seed"); - - let mut servers = BTreeMap::new(); - servers.insert( - "foo".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: "cmd".to_string(), - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - ); - - apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[mcp_servers] -# keep me -foo = { command = "cmd" } -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_replace_mcp_servers_preserves_inline_comment_suffix() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[mcp_servers] -foo = { command = "cmd" } # keep me -"#, - ) - .expect("seed"); - - let mut servers = BTreeMap::new(); - servers.insert( - "foo".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: "cmd".to_string(), - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: false, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - ); - - apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[mcp_servers] -foo = { command = "cmd" , enabled = false } # keep me -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_replace_mcp_servers_preserves_inline_comment_after_removing_keys() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[mcp_servers] -foo = { command = "cmd", args = ["--flag"] } # keep me -"#, - ) - .expect("seed"); - - let mut servers = BTreeMap::new(); - servers.insert( - "foo".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: "cmd".to_string(), - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - ); - - apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[mcp_servers] -foo = { command = "cmd"} # keep me -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_replace_mcp_servers_preserves_inline_comment_prefix_on_update() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[mcp_servers] -# keep me -foo = { command = "cmd" } -"#, - ) - .expect("seed"); - - let mut servers = BTreeMap::new(); - servers.insert( - "foo".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: "cmd".to_string(), - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: false, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - ); - - apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[mcp_servers] -# keep me -foo = { command = "cmd" , enabled = false } -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_clear_path_noop_when_missing() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - apply_blocking( - codex_home, - None, - &[ConfigEdit::ClearPath { - segments: vec!["missing".to_string()], - }], - ) - .expect("apply"); - - assert!( - !codex_home.join(CONFIG_TOML_FILE).exists(), - "config.toml should not be created on noop" - ); - } - - #[test] - fn blocking_set_path_updates_notifications() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - let item = value(false); - apply_blocking( - codex_home, - None, - &[ConfigEdit::SetPath { - segments: vec!["tui".to_string(), "notifications".to_string()], - value: item, - }], - ) - .expect("apply"); - - let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let config: TomlValue = toml::from_str(&raw).expect("parse config"); - let notifications = config - .get("tui") - .and_then(|item| item.as_table()) - .and_then(|tbl| tbl.get("notifications")) - .and_then(toml::Value::as_bool); - assert_eq!(notifications, Some(false)); - } - - #[tokio::test] - async fn async_builder_set_model_persists() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path().to_path_buf(); - - ConfigEditsBuilder::new(&codex_home) - .set_model(Some("gpt-5.1-codex"), Some(ReasoningEffort::High)) - .apply() - .await - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"model = "gpt-5.1-codex" -model_reasoning_effort = "high" -"#; - assert_eq!(contents, expected); - } - - #[test] - fn blocking_builder_set_model_round_trips_back_and_forth() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - let initial_expected = r#"model = "o4-mini" -model_reasoning_effort = "low" -"#; - ConfigEditsBuilder::new(codex_home) - .set_model(Some("o4-mini"), Some(ReasoningEffort::Low)) - .apply_blocking() - .expect("persist initial"); - let mut contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - assert_eq!(contents, initial_expected); - - let updated_expected = r#"model = "gpt-5.1-codex" -model_reasoning_effort = "high" -"#; - ConfigEditsBuilder::new(codex_home) - .set_model(Some("gpt-5.1-codex"), Some(ReasoningEffort::High)) - .apply_blocking() - .expect("persist update"); - contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - assert_eq!(contents, updated_expected); - - ConfigEditsBuilder::new(codex_home) - .set_model(Some("o4-mini"), Some(ReasoningEffort::Low)) - .apply_blocking() - .expect("persist revert"); - contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - assert_eq!(contents, initial_expected); - } - - #[tokio::test] - async fn blocking_set_asynchronous_helpers_available() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path().to_path_buf(); - - ConfigEditsBuilder::new(&codex_home) - .set_hide_full_access_warning(true) - .apply() - .await - .expect("persist"); - - let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let notice = toml::from_str::(&raw) - .expect("parse config") - .get("notice") - .and_then(|item| item.as_table()) - .and_then(|tbl| tbl.get("hide_full_access_warning")) - .and_then(toml::Value::as_bool); - assert_eq!(notice, Some(true)); - } - - #[test] - fn blocking_builder_set_realtime_audio_persists_and_clears() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - ConfigEditsBuilder::new(codex_home) - .set_realtime_microphone(Some("USB Mic")) - .set_realtime_speaker(Some("Desk Speakers")) - .apply_blocking() - .expect("persist realtime audio"); - - let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let config: TomlValue = toml::from_str(&raw).expect("parse config"); - let realtime_audio = config - .get("audio") - .and_then(TomlValue::as_table) - .expect("audio table should exist"); - assert_eq!( - realtime_audio.get("microphone").and_then(TomlValue::as_str), - Some("USB Mic") - ); - assert_eq!( - realtime_audio.get("speaker").and_then(TomlValue::as_str), - Some("Desk Speakers") - ); - - ConfigEditsBuilder::new(codex_home) - .set_realtime_microphone(None) - .apply_blocking() - .expect("clear realtime microphone"); - - let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let config: TomlValue = toml::from_str(&raw).expect("parse config"); - let realtime_audio = config - .get("audio") - .and_then(TomlValue::as_table) - .expect("audio table should exist"); - assert_eq!(realtime_audio.get("microphone"), None); - assert_eq!( - realtime_audio.get("speaker").and_then(TomlValue::as_str), - Some("Desk Speakers") - ); - } - - #[test] - fn replace_mcp_servers_blocking_clears_table_when_empty() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - "[mcp_servers]\nfoo = { command = \"cmd\" }\n", - ) - .expect("seed"); - - apply_blocking( - codex_home, - None, - &[ConfigEdit::ReplaceMcpServers(BTreeMap::new())], - ) - .expect("persist"); - - let contents = - std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - assert!(!contents.contains("mcp_servers")); - } -} +#[path = "edit_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs new file mode 100644 index 0000000000..5a31d84dd0 --- /dev/null +++ b/codex-rs/core/src/config/edit_tests.rs @@ -0,0 +1,987 @@ +use super::*; +use crate::config::types::McpServerTransportConfig; +use codex_protocol::openai_models::ReasoningEffort; +use pretty_assertions::assert_eq; +#[cfg(unix)] +use std::os::unix::fs::symlink; +use tempfile::tempdir; +use toml::Value as TomlValue; + +#[test] +fn blocking_set_model_top_level() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetModel { + model: Some("gpt-5.1-codex".to_string()), + effort: Some(ReasoningEffort::High), + }], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"model = "gpt-5.1-codex" +model_reasoning_effort = "high" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn builder_with_edits_applies_custom_paths() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits(vec![ConfigEdit::SetPath { + segments: vec!["enabled".to_string()], + value: value(true), + }]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, "enabled = true\n"); +} + +#[test] +fn set_model_availability_nux_count_writes_shown_count() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + let shown_count = HashMap::from([("gpt-foo".to_string(), 4)]); + + ConfigEditsBuilder::new(codex_home) + .set_model_availability_nux_count(&shown_count) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[tui.model_availability_nux] +gpt-foo = 4 +"#; + assert_eq!(contents, expected); +} + +#[test] +fn set_skill_config_writes_disabled_entry() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetSkillConfig { + path: PathBuf::from("/tmp/skills/demo/SKILL.md"), + enabled: false, + }]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[[skills.config]] +path = "/tmp/skills/demo/SKILL.md" +enabled = false +"#; + assert_eq!(contents, expected); +} + +#[test] +fn set_skill_config_removes_entry_when_enabled() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[[skills.config]] +path = "/tmp/skills/demo/SKILL.md" +enabled = false +"#, + ) + .expect("seed config"); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetSkillConfig { + path: PathBuf::from("/tmp/skills/demo/SKILL.md"), + enabled: true, + }]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, ""); +} + +#[test] +fn blocking_set_model_preserves_inline_table_contents() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + // Seed with inline tables for profiles to simulate common user config. + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"profile = "fast" + +profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } +"#, + ) + .expect("seed"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetModel { + model: Some("o4-mini".to_string()), + effort: None, + }], + ) + .expect("persist"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let value: TomlValue = toml::from_str(&raw).expect("parse config"); + + // Ensure sandbox_mode is preserved under profiles.fast and model updated. + let profiles_tbl = value + .get("profiles") + .and_then(|v| v.as_table()) + .expect("profiles table"); + let fast_tbl = profiles_tbl + .get("fast") + .and_then(|v| v.as_table()) + .expect("fast table"); + assert_eq!( + fast_tbl.get("sandbox_mode").and_then(|v| v.as_str()), + Some("strict") + ); + assert_eq!( + fast_tbl.get("model").and_then(|v| v.as_str()), + Some("o4-mini") + ); +} + +#[cfg(unix)] +#[test] +fn blocking_set_model_writes_through_symlink_chain() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + let target_dir = tempdir().expect("target dir"); + let target_path = target_dir.path().join(CONFIG_TOML_FILE); + let link_path = codex_home.join("config-link.toml"); + let config_path = codex_home.join(CONFIG_TOML_FILE); + + symlink(&target_path, &link_path).expect("symlink link"); + symlink("config-link.toml", &config_path).expect("symlink config"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetModel { + model: Some("gpt-5.1-codex".to_string()), + effort: Some(ReasoningEffort::High), + }], + ) + .expect("persist"); + + let meta = std::fs::symlink_metadata(&config_path).expect("config metadata"); + assert!(meta.file_type().is_symlink()); + + let contents = std::fs::read_to_string(&target_path).expect("read target"); + let expected = r#"model = "gpt-5.1-codex" +model_reasoning_effort = "high" +"#; + assert_eq!(contents, expected); +} + +#[cfg(unix)] +#[test] +fn blocking_set_model_replaces_symlink_on_cycle() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + let link_a = codex_home.join("a.toml"); + let link_b = codex_home.join("b.toml"); + let config_path = codex_home.join(CONFIG_TOML_FILE); + + symlink("b.toml", &link_a).expect("symlink a"); + symlink("a.toml", &link_b).expect("symlink b"); + symlink("a.toml", &config_path).expect("symlink config"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetModel { + model: Some("gpt-5.1-codex".to_string()), + effort: None, + }], + ) + .expect("persist"); + + let meta = std::fs::symlink_metadata(&config_path).expect("config metadata"); + assert!(!meta.file_type().is_symlink()); + + let contents = std::fs::read_to_string(&config_path).expect("read config"); + let expected = r#"model = "gpt-5.1-codex" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn batch_write_table_upsert_preserves_inline_comments() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + let original = r#"approval_policy = "never" + +[mcp_servers.linear] +name = "linear" +# ok +url = "https://linear.example" + +[mcp_servers.linear.http_headers] +foo = "bar" + +[sandbox_workspace_write] +# ok 3 +network_access = false +"#; + std::fs::write(codex_home.join(CONFIG_TOML_FILE), original).expect("seed config"); + + apply_blocking( + codex_home, + None, + &[ + ConfigEdit::SetPath { + segments: vec![ + "mcp_servers".to_string(), + "linear".to_string(), + "url".to_string(), + ], + value: value("https://linear.example/v2"), + }, + ConfigEdit::SetPath { + segments: vec![ + "sandbox_workspace_write".to_string(), + "network_access".to_string(), + ], + value: value(true), + }, + ], + ) + .expect("apply"); + + let updated = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"approval_policy = "never" + +[mcp_servers.linear] +name = "linear" +# ok +url = "https://linear.example/v2" + +[mcp_servers.linear.http_headers] +foo = "bar" + +[sandbox_workspace_write] +# ok 3 +network_access = true +"#; + assert_eq!(updated, expected); +} + +#[test] +fn blocking_clear_model_removes_inline_table_entry() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"profile = "fast" + +profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } +"#, + ) + .expect("seed"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetModel { + model: None, + effort: Some(ReasoningEffort::High), + }], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"profile = "fast" + +[profiles.fast] +sandbox_mode = "strict" +model_reasoning_effort = "high" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_set_model_scopes_to_active_profile() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"profile = "team" + +[profiles.team] +model_reasoning_effort = "low" +"#, + ) + .expect("seed"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetModel { + model: Some("o5-preview".to_string()), + effort: Some(ReasoningEffort::Minimal), + }], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"profile = "team" + +[profiles.team] +model_reasoning_effort = "minimal" +model = "o5-preview" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_set_model_with_explicit_profile() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[profiles."team a"] +model = "gpt-5.1-codex" +"#, + ) + .expect("seed"); + + apply_blocking( + codex_home, + Some("team a"), + &[ConfigEdit::SetModel { + model: Some("o4-mini".to_string()), + effort: None, + }], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[profiles."team a"] +model = "o4-mini" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_set_hide_full_access_warning_preserves_table() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"# Global comment + +[notice] +# keep me +existing = "value" +"#, + ) + .expect("seed"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetNoticeHideFullAccessWarning(true)], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"# Global comment + +[notice] +# keep me +existing = "value" +hide_full_access_warning = true +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_set_hide_rate_limit_model_nudge_preserves_table() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[notice] +existing = "value" +"#, + ) + .expect("seed"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[notice] +existing = "value" +hide_rate_limit_model_nudge = true +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_set_hide_gpt5_1_migration_prompt_preserves_table() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[notice] +existing = "value" +"#, + ) + .expect("seed"); + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetNoticeHideModelMigrationPrompt( + "hide_gpt5_1_migration_prompt".to_string(), + true, + )], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[notice] +existing = "value" +hide_gpt5_1_migration_prompt = true +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_set_hide_gpt_5_1_codex_max_migration_prompt_preserves_table() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[notice] +existing = "value" +"#, + ) + .expect("seed"); + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetNoticeHideModelMigrationPrompt( + "hide_gpt-5.1-codex-max_migration_prompt".to_string(), + true, + )], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[notice] +existing = "value" +"hide_gpt-5.1-codex-max_migration_prompt" = true +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_record_model_migration_seen_preserves_table() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[notice] +existing = "value" +"#, + ) + .expect("seed"); + apply_blocking( + codex_home, + None, + &[ConfigEdit::RecordModelMigrationSeen { + from: "gpt-5".to_string(), + to: "gpt-5.1".to_string(), + }], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[notice] +existing = "value" + +[notice.model_migrations] +gpt-5 = "gpt-5.1" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_replace_mcp_servers_round_trips() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + let mut servers = BTreeMap::new(); + servers.insert( + "stdio".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "cmd".to_string(), + args: vec!["--flag".to_string()], + env: Some( + [ + ("B".to_string(), "2".to_string()), + ("A".to_string(), "1".to_string()), + ] + .into_iter() + .collect(), + ), + env_vars: vec!["FOO".to_string()], + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: Some(vec!["one".to_string(), "two".to_string()]), + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + ); + + servers.insert( + "http".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com".to_string(), + bearer_token_env_var: Some("TOKEN".to_string()), + http_headers: Some( + [("Z-Header".to_string(), "z".to_string())] + .into_iter() + .collect(), + ), + env_http_headers: None, + }, + enabled: false, + required: false, + disabled_reason: None, + startup_timeout_sec: Some(std::time::Duration::from_secs(5)), + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: Some(vec!["forbidden".to_string()]), + scopes: None, + oauth_resource: Some("https://resource.example.com".to_string()), + }, + ); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + ) + .expect("persist"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = "\ +[mcp_servers.http] +url = \"https://example.com\" +bearer_token_env_var = \"TOKEN\" +enabled = false +startup_timeout_sec = 5.0 +disabled_tools = [\"forbidden\"] +oauth_resource = \"https://resource.example.com\" + +[mcp_servers.http.http_headers] +Z-Header = \"z\" + +[mcp_servers.stdio] +command = \"cmd\" +args = [\"--flag\"] +env_vars = [\"FOO\"] +enabled_tools = [\"one\", \"two\"] + +[mcp_servers.stdio.env] +A = \"1\" +B = \"2\" +"; + assert_eq!(raw, expected); +} + +#[test] +fn blocking_replace_mcp_servers_preserves_inline_comments() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[mcp_servers] +# keep me +foo = { command = "cmd" } +"#, + ) + .expect("seed"); + + let mut servers = BTreeMap::new(); + servers.insert( + "foo".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "cmd".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + ); + + apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[mcp_servers] +# keep me +foo = { command = "cmd" } +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_replace_mcp_servers_preserves_inline_comment_suffix() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[mcp_servers] +foo = { command = "cmd" } # keep me +"#, + ) + .expect("seed"); + + let mut servers = BTreeMap::new(); + servers.insert( + "foo".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "cmd".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: false, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + ); + + apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[mcp_servers] +foo = { command = "cmd" , enabled = false } # keep me +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_replace_mcp_servers_preserves_inline_comment_after_removing_keys() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[mcp_servers] +foo = { command = "cmd", args = ["--flag"] } # keep me +"#, + ) + .expect("seed"); + + let mut servers = BTreeMap::new(); + servers.insert( + "foo".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "cmd".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + ); + + apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[mcp_servers] +foo = { command = "cmd"} # keep me +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_replace_mcp_servers_preserves_inline_comment_prefix_on_update() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[mcp_servers] +# keep me +foo = { command = "cmd" } +"#, + ) + .expect("seed"); + + let mut servers = BTreeMap::new(); + servers.insert( + "foo".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "cmd".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: false, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + ); + + apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[mcp_servers] +# keep me +foo = { command = "cmd" , enabled = false } +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_clear_path_noop_when_missing() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::ClearPath { + segments: vec!["missing".to_string()], + }], + ) + .expect("apply"); + + assert!( + !codex_home.join(CONFIG_TOML_FILE).exists(), + "config.toml should not be created on noop" + ); +} + +#[test] +fn blocking_set_path_updates_notifications() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + let item = value(false); + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "notifications".to_string()], + value: item, + }], + ) + .expect("apply"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let config: TomlValue = toml::from_str(&raw).expect("parse config"); + let notifications = config + .get("tui") + .and_then(|item| item.as_table()) + .and_then(|tbl| tbl.get("notifications")) + .and_then(toml::Value::as_bool); + assert_eq!(notifications, Some(false)); +} + +#[tokio::test] +async fn async_builder_set_model_persists() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path().to_path_buf(); + + ConfigEditsBuilder::new(&codex_home) + .set_model(Some("gpt-5.1-codex"), Some(ReasoningEffort::High)) + .apply() + .await + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"model = "gpt-5.1-codex" +model_reasoning_effort = "high" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_builder_set_model_round_trips_back_and_forth() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + let initial_expected = r#"model = "o4-mini" +model_reasoning_effort = "low" +"#; + ConfigEditsBuilder::new(codex_home) + .set_model(Some("o4-mini"), Some(ReasoningEffort::Low)) + .apply_blocking() + .expect("persist initial"); + let mut contents = + std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, initial_expected); + + let updated_expected = r#"model = "gpt-5.1-codex" +model_reasoning_effort = "high" +"#; + ConfigEditsBuilder::new(codex_home) + .set_model(Some("gpt-5.1-codex"), Some(ReasoningEffort::High)) + .apply_blocking() + .expect("persist update"); + contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, updated_expected); + + ConfigEditsBuilder::new(codex_home) + .set_model(Some("o4-mini"), Some(ReasoningEffort::Low)) + .apply_blocking() + .expect("persist revert"); + contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, initial_expected); +} + +#[tokio::test] +async fn blocking_set_asynchronous_helpers_available() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path().to_path_buf(); + + ConfigEditsBuilder::new(&codex_home) + .set_hide_full_access_warning(true) + .apply() + .await + .expect("persist"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let notice = toml::from_str::(&raw) + .expect("parse config") + .get("notice") + .and_then(|item| item.as_table()) + .and_then(|tbl| tbl.get("hide_full_access_warning")) + .and_then(toml::Value::as_bool); + assert_eq!(notice, Some(true)); +} + +#[test] +fn blocking_builder_set_realtime_audio_persists_and_clears() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .set_realtime_microphone(Some("USB Mic")) + .set_realtime_speaker(Some("Desk Speakers")) + .apply_blocking() + .expect("persist realtime audio"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let config: TomlValue = toml::from_str(&raw).expect("parse config"); + let realtime_audio = config + .get("audio") + .and_then(TomlValue::as_table) + .expect("audio table should exist"); + assert_eq!( + realtime_audio.get("microphone").and_then(TomlValue::as_str), + Some("USB Mic") + ); + assert_eq!( + realtime_audio.get("speaker").and_then(TomlValue::as_str), + Some("Desk Speakers") + ); + + ConfigEditsBuilder::new(codex_home) + .set_realtime_microphone(None) + .apply_blocking() + .expect("clear realtime microphone"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let config: TomlValue = toml::from_str(&raw).expect("parse config"); + let realtime_audio = config + .get("audio") + .and_then(TomlValue::as_table) + .expect("audio table should exist"); + assert_eq!(realtime_audio.get("microphone"), None); + assert_eq!( + realtime_audio.get("speaker").and_then(TomlValue::as_str), + Some("Desk Speakers") + ); +} + +#[test] +fn replace_mcp_servers_blocking_clears_table_when_empty() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + "[mcp_servers]\nfoo = { command = \"cmd\" }\n", + ) + .expect("seed"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::ReplaceMcpServers(BTreeMap::new())], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert!(!contents.contains("mcp_servers")); +} diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 9c70cf084c..de77e4426e 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -280,207 +280,5 @@ impl NetworkProxySpec { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn build_state_with_audit_metadata_threads_metadata_to_state() { - let spec = NetworkProxySpec { - config: NetworkProxyConfig::default(), - constraints: NetworkProxyConstraints::default(), - hard_deny_allowlist_misses: false, - }; - let metadata = NetworkProxyAuditMetadata { - conversation_id: Some("conversation-1".to_string()), - app_version: Some("1.2.3".to_string()), - user_account_id: Some("acct-1".to_string()), - ..NetworkProxyAuditMetadata::default() - }; - - let state = spec - .build_state_with_audit_metadata(metadata.clone()) - .expect("state should build"); - assert_eq!(state.audit_metadata(), &metadata); - } - - #[test] - fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { - let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; - let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), - ..Default::default() - }; - - let spec = NetworkProxySpec::from_config_and_constraints( - config, - Some(requirements), - &SandboxPolicy::new_read_only_policy(), - ) - .expect("config should stay within the managed allowlist"); - - assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string(), "api.example.com".to_string()] - ); - assert_eq!( - spec.constraints.allowed_domains, - Some(vec!["*.example.com".to_string()]) - ); - assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); - } - - #[test] - fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { - let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["evil.com".to_string()]; - config.network.denied_domains = vec!["more-blocked.example.com".to_string()]; - let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), - ..Default::default() - }; - - let spec = NetworkProxySpec::from_config_and_constraints( - config, - Some(requirements), - &SandboxPolicy::DangerFullAccess, - ) - .expect("yolo mode should pin the effective policy to the managed baseline"); - - assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string()] - ); - assert_eq!( - spec.config.network.denied_domains, - vec!["blocked.example.com".to_string()] - ); - assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); - assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false)); - } - - #[test] - fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { - let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; - let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), - managed_allowed_domains_only: Some(true), - ..Default::default() - }; - - let spec = NetworkProxySpec::from_config_and_constraints( - config, - Some(requirements), - &SandboxPolicy::new_workspace_write_policy(), - ) - .expect("managed baseline should still load"); - - assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string()] - ); - assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); - } - - #[test] - fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() { - let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; - let requirements = NetworkConstraints { - allowed_domains: Some(vec!["managed.example.com".to_string()]), - managed_allowed_domains_only: Some(true), - ..Default::default() - }; - - let spec = NetworkProxySpec::from_config_and_constraints( - config, - Some(requirements), - &SandboxPolicy::new_workspace_write_policy(), - ) - .expect("managed-only allowlist should still load"); - - assert_eq!( - spec.config.network.allowed_domains, - vec!["managed.example.com".to_string()] - ); - assert_eq!( - spec.constraints.allowed_domains, - Some(vec!["managed.example.com".to_string()]) - ); - assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); - assert!(spec.hard_deny_allowlist_misses); - } - - #[test] - fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domains() { - let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; - let requirements = NetworkConstraints { - managed_allowed_domains_only: Some(true), - ..Default::default() - }; - - let spec = NetworkProxySpec::from_config_and_constraints( - config, - Some(requirements), - &SandboxPolicy::new_workspace_write_policy(), - ) - .expect("managed-only mode should treat missing managed allowlist as empty"); - - assert!(spec.config.network.allowed_domains.is_empty()); - assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); - assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); - assert!(spec.hard_deny_allowlist_misses); - } - - #[test] - fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_managed_list() { - let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; - let requirements = NetworkConstraints { - managed_allowed_domains_only: Some(true), - ..Default::default() - }; - - let spec = NetworkProxySpec::from_config_and_constraints( - config, - Some(requirements), - &SandboxPolicy::DangerFullAccess, - ) - .expect("managed-only mode should treat missing managed allowlist as empty"); - - assert!(spec.config.network.allowed_domains.is_empty()); - assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); - assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); - assert!(spec.hard_deny_allowlist_misses); - } - - #[test] - fn requirements_denied_domains_are_a_baseline_for_default_mode() { - let mut config = NetworkProxyConfig::default(); - config.network.denied_domains = vec!["blocked.example.com".to_string()]; - let requirements = NetworkConstraints { - denied_domains: Some(vec!["managed-blocked.example.com".to_string()]), - ..Default::default() - }; - - let spec = NetworkProxySpec::from_config_and_constraints( - config, - Some(requirements), - &SandboxPolicy::new_workspace_write_policy(), - ) - .expect("default mode should merge managed and user deny entries"); - - assert_eq!( - spec.config.network.denied_domains, - vec![ - "managed-blocked.example.com".to_string(), - "blocked.example.com".to_string() - ] - ); - assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true)); - } -} +#[path = "network_proxy_spec_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs new file mode 100644 index 0000000000..4c6e82358e --- /dev/null +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -0,0 +1,202 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn build_state_with_audit_metadata_threads_metadata_to_state() { + let spec = NetworkProxySpec { + config: NetworkProxyConfig::default(), + constraints: NetworkProxyConstraints::default(), + hard_deny_allowlist_misses: false, + }; + let metadata = NetworkProxyAuditMetadata { + conversation_id: Some("conversation-1".to_string()), + app_version: Some("1.2.3".to_string()), + user_account_id: Some("acct-1".to_string()), + ..NetworkProxyAuditMetadata::default() + }; + + let state = spec + .build_state_with_audit_metadata(metadata.clone()) + .expect("state should build"); + assert_eq!(state.audit_metadata(), &metadata); +} + +#[test] +fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["api.example.com".to_string()]; + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_read_only_policy(), + ) + .expect("config should stay within the managed allowlist"); + + assert_eq!( + spec.config.network.allowed_domains, + vec!["*.example.com".to_string(), "api.example.com".to_string()] + ); + assert_eq!( + spec.constraints.allowed_domains, + Some(vec!["*.example.com".to_string()]) + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); +} + +#[test] +fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["evil.com".to_string()]; + config.network.denied_domains = vec!["more-blocked.example.com".to_string()]; + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("yolo mode should pin the effective policy to the managed baseline"); + + assert_eq!( + spec.config.network.allowed_domains, + vec!["*.example.com".to_string()] + ); + assert_eq!( + spec.config.network.denied_domains, + vec!["blocked.example.com".to_string()] + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false)); +} + +#[test] +fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["api.example.com".to_string()]; + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed baseline should still load"); + + assert_eq!( + spec.config.network.allowed_domains, + vec!["*.example.com".to_string()] + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); +} + +#[test] +fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["api.example.com".to_string()]; + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["managed.example.com".to_string()]), + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed-only allowlist should still load"); + + assert_eq!( + spec.config.network.allowed_domains, + vec!["managed.example.com".to_string()] + ); + assert_eq!( + spec.constraints.allowed_domains, + Some(vec!["managed.example.com".to_string()]) + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + assert!(spec.hard_deny_allowlist_misses); +} + +#[test] +fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domains() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["api.example.com".to_string()]; + let requirements = NetworkConstraints { + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed-only mode should treat missing managed allowlist as empty"); + + assert!(spec.config.network.allowed_domains.is_empty()); + assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + assert!(spec.hard_deny_allowlist_misses); +} + +#[test] +fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_managed_list() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["api.example.com".to_string()]; + let requirements = NetworkConstraints { + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("managed-only mode should treat missing managed allowlist as empty"); + + assert!(spec.config.network.allowed_domains.is_empty()); + assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + assert!(spec.hard_deny_allowlist_misses); +} + +#[test] +fn requirements_denied_domains_are_a_baseline_for_default_mode() { + let mut config = NetworkProxyConfig::default(); + config.network.denied_domains = vec!["blocked.example.com".to_string()]; + let requirements = NetworkConstraints { + denied_domains: Some(vec!["managed-blocked.example.com".to_string()]), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("default mode should merge managed and user deny entries"); + + assert_eq!( + spec.config.network.denied_domains, + vec![ + "managed-blocked.example.com".to_string(), + "blocked.example.com".to_string() + ] + ); + assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true)); +} diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index b931c0f415..0ad98068fa 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -410,14 +410,5 @@ fn maybe_push_unknown_special_path_warning( } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() { - let parsed = - normalize_absolute_path_for_platform(r"\\?\D:\c\x\worktrees\2508\swift-base", true); - assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base")); - } -} +#[path = "permissions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs new file mode 100644 index 0000000000..036c8450cd --- /dev/null +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -0,0 +1,9 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() { + let parsed = + normalize_absolute_path_for_platform(r"\\?\D:\c\x\worktrees\2508\swift-base", true); + assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base")); +} diff --git a/codex-rs/core/src/config/schema.rs b/codex-rs/core/src/config/schema.rs index 95aea130e6..851f4d19ee 100644 --- a/codex-rs/core/src/config/schema.rs +++ b/codex-rs/core/src/config/schema.rs @@ -96,54 +96,5 @@ pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> { } #[cfg(test)] -mod tests { - use super::canonicalize; - use super::config_schema_json; - use super::write_config_schema; - - use pretty_assertions::assert_eq; - use similar::TextDiff; - use tempfile::TempDir; - - #[test] - fn config_schema_matches_fixture() { - let fixture_path = codex_utils_cargo_bin::find_resource!("config.schema.json") - .expect("resolve config schema fixture path"); - let fixture = std::fs::read_to_string(fixture_path).expect("read config schema fixture"); - let fixture_value: serde_json::Value = - serde_json::from_str(&fixture).expect("parse config schema fixture"); - let schema_json = config_schema_json().expect("serialize config schema"); - let schema_value: serde_json::Value = - serde_json::from_slice(&schema_json).expect("decode schema json"); - let fixture_value = canonicalize(&fixture_value); - let schema_value = canonicalize(&schema_value); - if fixture_value != schema_value { - let expected = - serde_json::to_string_pretty(&fixture_value).expect("serialize fixture json"); - let actual = - serde_json::to_string_pretty(&schema_value).expect("serialize schema json"); - let diff = TextDiff::from_lines(&expected, &actual) - .unified_diff() - .header("fixture", "generated") - .to_string(); - panic!( - "Current schema for `config.toml` doesn't match the fixture. \ -Run `just write-config-schema` to overwrite with your changes.\n\n{diff}" - ); - } - - // Make sure the version in the repo matches exactly: https://github.com/openai/codex/pull/10977. - let tmp = TempDir::new().expect("create temp dir"); - let tmp_path = tmp.path().join("config.schema.json"); - write_config_schema(&tmp_path).expect("write config schema to temp path"); - let tmp_contents = - std::fs::read_to_string(&tmp_path).expect("read back config schema from temp path"); - #[cfg(windows)] - let fixture = fixture.replace("\r\n", "\n"); - - assert_eq!( - fixture, tmp_contents, - "fixture should match exactly with generated schema" - ); - } -} +#[path = "schema_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/config/schema_tests.rs b/codex-rs/core/src/config/schema_tests.rs new file mode 100644 index 0000000000..6205d43f40 --- /dev/null +++ b/codex-rs/core/src/config/schema_tests.rs @@ -0,0 +1,48 @@ +use super::canonicalize; +use super::config_schema_json; +use super::write_config_schema; + +use pretty_assertions::assert_eq; +use similar::TextDiff; +use tempfile::TempDir; + +#[test] +fn config_schema_matches_fixture() { + let fixture_path = codex_utils_cargo_bin::find_resource!("config.schema.json") + .expect("resolve config schema fixture path"); + let fixture = std::fs::read_to_string(fixture_path).expect("read config schema fixture"); + let fixture_value: serde_json::Value = + serde_json::from_str(&fixture).expect("parse config schema fixture"); + let schema_json = config_schema_json().expect("serialize config schema"); + let schema_value: serde_json::Value = + serde_json::from_slice(&schema_json).expect("decode schema json"); + let fixture_value = canonicalize(&fixture_value); + let schema_value = canonicalize(&schema_value); + if fixture_value != schema_value { + let expected = + serde_json::to_string_pretty(&fixture_value).expect("serialize fixture json"); + let actual = serde_json::to_string_pretty(&schema_value).expect("serialize schema json"); + let diff = TextDiff::from_lines(&expected, &actual) + .unified_diff() + .header("fixture", "generated") + .to_string(); + panic!( + "Current schema for `config.toml` doesn't match the fixture. \ +Run `just write-config-schema` to overwrite with your changes.\n\n{diff}" + ); + } + + // Make sure the version in the repo matches exactly: https://github.com/openai/codex/pull/10977. + let tmp = TempDir::new().expect("create temp dir"); + let tmp_path = tmp.path().join("config.schema.json"); + write_config_schema(&tmp_path).expect("write config schema to temp path"); + let tmp_contents = + std::fs::read_to_string(&tmp_path).expect("read back config schema from temp path"); + #[cfg(windows)] + let fixture = fixture.replace("\r\n", "\n"); + + assert_eq!( + fixture, tmp_contents, + "fixture should match exactly with generated schema" + ); +} diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index df344afb40..7d3e2200e9 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -731,690 +731,5 @@ fn find_effective_layer( } #[cfg(test)] -mod tests { - use super::*; - use anyhow::Result; - use codex_app_server_protocol::AppConfig; - use codex_app_server_protocol::AppToolApproval; - use codex_app_server_protocol::AppsConfig; - use codex_app_server_protocol::AskForApproval; - use codex_utils_absolute_path::AbsolutePathBuf; - use pretty_assertions::assert_eq; - use std::collections::BTreeMap; - use tempfile::tempdir; - - #[test] - fn toml_value_to_item_handles_nested_config_tables() { - let config = r#" -[mcp_servers.docs] -command = "docs-server" - -[mcp_servers.docs.http_headers] -X-Doc = "42" -"#; - - let value: TomlValue = toml::from_str(config).expect("parse config example"); - let item = toml_value_to_item(&value).expect("convert to toml_edit item"); - - let root = item.as_table().expect("root table"); - assert!(!root.is_implicit(), "root table should be explicit"); - - let mcp_servers = root - .get("mcp_servers") - .and_then(TomlItem::as_table) - .expect("mcp_servers table"); - assert!( - !mcp_servers.is_implicit(), - "mcp_servers table should be explicit" - ); - - let docs = mcp_servers - .get("docs") - .and_then(TomlItem::as_table) - .expect("docs table"); - assert_eq!( - docs.get("command") - .and_then(TomlItem::as_value) - .and_then(toml_edit::Value::as_str), - Some("docs-server") - ); - - let http_headers = docs - .get("http_headers") - .and_then(TomlItem::as_table) - .expect("http_headers table"); - assert_eq!( - http_headers - .get("X-Doc") - .and_then(TomlItem::as_value) - .and_then(toml_edit::Value::as_str), - Some("42") - ); - } - - #[tokio::test] - async fn write_value_preserves_comments_and_order() -> Result<()> { - let tmp = tempdir().expect("tempdir"); - let original = r#"# Codex user configuration -model = "gpt-5" -approval_policy = "on-request" - -[notice] -# Preserve this comment -hide_full_access_warning = true - -[features] -unified_exec = true -"#; - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), original)?; - - let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); - service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "features.personality".to_string(), - value: serde_json::json!(true), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect("write succeeds"); - - let updated = - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"# Codex user configuration -model = "gpt-5" -approval_policy = "on-request" - -[notice] -# Preserve this comment -hide_full_access_warning = true - -[features] -unified_exec = true -personality = true -"#; - assert_eq!(updated, expected); - Ok(()) - } - - #[tokio::test] - async fn write_value_supports_nested_app_paths() -> Result<()> { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?; - - let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); - service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "apps".to_string(), - value: serde_json::json!({ - "app1": { - "enabled": false, - }, - }), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect("write apps succeeds"); - - service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "apps.app1.default_tools_approval_mode".to_string(), - value: serde_json::json!("prompt"), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect("write apps.app1.default_tools_approval_mode succeeds"); - - let read = service - .read(ConfigReadParams { - include_layers: false, - cwd: None, - }) - .await - .expect("config read succeeds"); - - assert_eq!( - read.config.apps, - Some(AppsConfig { - default: None, - apps: std::collections::HashMap::from([( - "app1".to_string(), - AppConfig { - enabled: false, - destructive_enabled: None, - open_world_enabled: None, - default_tools_approval_mode: Some(AppToolApproval::Prompt), - default_tools_enabled: None, - tools: None, - }, - )]), - }) - ); - - Ok(()) - } - - #[tokio::test] - async fn read_includes_origins_and_layers() { - let tmp = tempdir().expect("tempdir"); - let user_path = tmp.path().join(CONFIG_TOML_FILE); - std::fs::write(&user_path, "model = \"user\"").unwrap(); - let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file"); - - let managed_path = tmp.path().join("managed_config.toml"); - std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); - let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); - - let service = ConfigService::new( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, - CloudRequirementsLoader::default(), - ); - - let response = service - .read(ConfigReadParams { - include_layers: true, - cwd: None, - }) - .await - .expect("response"); - - assert_eq!(response.config.approval_policy, Some(AskForApproval::Never)); - - assert_eq!( - response - .origins - .get("approval_policy") - .expect("origin") - .name, - ConfigLayerSource::LegacyManagedConfigTomlFromFile { - file: managed_file.clone() - }, - ); - let layers = response.layers.expect("layers present"); - // Local macOS machines can surface an MDM-managed config layer at the - // top of the stack; ignore it so this test stays focused on file/user/system ordering. - let layers = if matches!( - layers.first().map(|layer| &layer.name), - Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm) - ) { - &layers[1..] - } else { - layers.as_slice() - }; - assert_eq!(layers.len(), 3, "expected three layers"); - assert_eq!( - layers.first().unwrap().name, - ConfigLayerSource::LegacyManagedConfigTomlFromFile { - file: managed_file.clone() - } - ); - assert_eq!( - layers.get(1).unwrap().name, - ConfigLayerSource::User { - file: user_file.clone() - } - ); - assert!(matches!( - layers.get(2).unwrap().name, - ConfigLayerSource::System { .. } - )); - } - - #[tokio::test] - async fn write_value_reports_override() { - let tmp = tempdir().expect("tempdir"); - std::fs::write( - tmp.path().join(CONFIG_TOML_FILE), - "approval_policy = \"on-request\"", - ) - .unwrap(); - - let managed_path = tmp.path().join("managed_config.toml"); - std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); - let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); - - let service = ConfigService::new( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, - CloudRequirementsLoader::default(), - ); - - let result = service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "approval_policy".to_string(), - value: serde_json::json!("never"), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect("result"); - - let read_after = service - .read(ConfigReadParams { - include_layers: true, - cwd: None, - }) - .await - .expect("read"); - assert_eq!( - read_after.config.approval_policy, - Some(AskForApproval::Never) - ); - assert_eq!( - read_after - .origins - .get("approval_policy") - .expect("origin") - .name, - ConfigLayerSource::LegacyManagedConfigTomlFromFile { - file: managed_file.clone() - } - ); - assert_eq!(result.status, WriteStatus::Ok); - assert!(result.overridden_metadata.is_none()); - } - - #[tokio::test] - async fn version_conflict_rejected() { - let tmp = tempdir().expect("tempdir"); - let user_path = tmp.path().join(CONFIG_TOML_FILE); - std::fs::write(&user_path, "model = \"user\"").unwrap(); - - let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); - let error = service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "model".to_string(), - value: serde_json::json!("gpt-5"), - merge_strategy: MergeStrategy::Replace, - expected_version: Some("sha256:bogus".to_string()), - }) - .await - .expect_err("should fail"); - - assert_eq!( - error.write_error_code(), - Some(ConfigWriteErrorCode::ConfigVersionConflict) - ); - } - - #[tokio::test] - async fn write_value_defaults_to_user_config_path() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); - - let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); - service - .write_value(ConfigValueWriteParams { - file_path: None, - key_path: "model".to_string(), - value: serde_json::json!("gpt-new"), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect("write succeeds"); - - let contents = - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); - assert!( - contents.contains("model = \"gpt-new\""), - "config.toml should be updated even when file_path is omitted" - ); - } - - #[tokio::test] - async fn invalid_user_value_rejected_even_if_overridden_by_managed() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap(); - - let managed_path = tmp.path().join("managed_config.toml"); - std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); - - let service = ConfigService::new( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, - CloudRequirementsLoader::default(), - ); - - let error = service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "approval_policy".to_string(), - value: serde_json::json!("bogus"), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect_err("should fail validation"); - - assert_eq!( - error.write_error_code(), - Some(ConfigWriteErrorCode::ConfigValidationError) - ); - - let contents = - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); - assert_eq!(contents.trim(), "model = \"user\""); - } - - #[tokio::test] - async fn write_value_rejects_feature_requirement_conflict() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); - - let service = ConfigService::new( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: None, - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { - entries: BTreeMap::from([("personality".to_string(), true)]), - }), - ..Default::default() - })) - }), - ); - - let error = service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "features.personality".to_string(), - value: serde_json::json!(false), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect_err("conflicting feature write should fail"); - - assert_eq!( - error.write_error_code(), - Some(ConfigWriteErrorCode::ConfigValidationError) - ); - assert!( - error - .to_string() - .contains("invalid value for `features`: `features.personality=false`"), - "{error}" - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(), - "" - ); - } - - #[tokio::test] - async fn write_value_rejects_profile_feature_requirement_conflict() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); - - let service = ConfigService::new( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: None, - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { - entries: BTreeMap::from([("personality".to_string(), true)]), - }), - ..Default::default() - })) - }), - ); - - let error = service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "profiles.enterprise.features.personality".to_string(), - value: serde_json::json!(false), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect_err("conflicting profile feature write should fail"); - - assert_eq!( - error.write_error_code(), - Some(ConfigWriteErrorCode::ConfigValidationError) - ); - assert!( - error.to_string().contains( - "invalid value for `features`: `profiles.enterprise.features.personality=false`" - ), - "{error}" - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(), - "" - ); - } - - #[tokio::test] - async fn read_reports_managed_overrides_user_and_session_flags() { - let tmp = tempdir().expect("tempdir"); - let user_path = tmp.path().join(CONFIG_TOML_FILE); - std::fs::write(&user_path, "model = \"user\"").unwrap(); - let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file"); - - let managed_path = tmp.path().join("managed_config.toml"); - std::fs::write(&managed_path, "model = \"system\"").unwrap(); - let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); - - let cli_overrides = vec![( - "model".to_string(), - TomlValue::String("session".to_string()), - )]; - - let service = ConfigService::new( - tmp.path().to_path_buf(), - cli_overrides, - LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, - CloudRequirementsLoader::default(), - ); - - let response = service - .read(ConfigReadParams { - include_layers: true, - cwd: None, - }) - .await - .expect("response"); - - assert_eq!(response.config.model.as_deref(), Some("system")); - assert_eq!( - response.origins.get("model").expect("origin").name, - ConfigLayerSource::LegacyManagedConfigTomlFromFile { - file: managed_file.clone() - }, - ); - let layers = response.layers.expect("layers"); - // Local macOS machines can surface an MDM-managed config layer at the - // top of the stack; ignore it so this test stays focused on file/session/user ordering. - let layers = if matches!( - layers.first().map(|layer| &layer.name), - Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm) - ) { - &layers[1..] - } else { - layers.as_slice() - }; - assert_eq!( - layers.first().unwrap().name, - ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file } - ); - assert_eq!(layers.get(1).unwrap().name, ConfigLayerSource::SessionFlags); - assert_eq!( - layers.get(2).unwrap().name, - ConfigLayerSource::User { file: user_file } - ); - } - - #[tokio::test] - async fn write_value_reports_managed_override() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); - - let managed_path = tmp.path().join("managed_config.toml"); - std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); - let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); - - let service = ConfigService::new( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, - CloudRequirementsLoader::default(), - ); - - let result = service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "approval_policy".to_string(), - value: serde_json::json!("on-request"), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect("result"); - - assert_eq!(result.status, WriteStatus::OkOverridden); - let overridden = result.overridden_metadata.expect("overridden metadata"); - assert_eq!( - overridden.overriding_layer.name, - ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file } - ); - assert_eq!(overridden.effective_value, serde_json::json!("never")); - } - - #[tokio::test] - async fn upsert_merges_tables_replace_overwrites() -> Result<()> { - let tmp = tempdir().expect("tempdir"); - let path = tmp.path().join(CONFIG_TOML_FILE); - let base = r#"[mcp_servers.linear] -bearer_token_env_var = "TOKEN" -name = "linear" -url = "https://linear.example" - -[mcp_servers.linear.env_http_headers] -existing = "keep" - -[mcp_servers.linear.http_headers] -alpha = "a" -"#; - - let overlay = serde_json::json!({ - "bearer_token_env_var": "NEW_TOKEN", - "http_headers": { - "alpha": "updated", - "beta": "b" - }, - "name": "linear", - "url": "https://linear.example" - }); - - std::fs::write(&path, base)?; - - let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); - service - .write_value(ConfigValueWriteParams { - file_path: Some(path.display().to_string()), - key_path: "mcp_servers.linear".to_string(), - value: overlay.clone(), - merge_strategy: MergeStrategy::Upsert, - expected_version: None, - }) - .await - .expect("upsert succeeds"); - - let upserted: TomlValue = toml::from_str(&std::fs::read_to_string(&path)?)?; - let expected_upsert: TomlValue = toml::from_str( - r#"[mcp_servers.linear] -bearer_token_env_var = "NEW_TOKEN" -name = "linear" -url = "https://linear.example" - -[mcp_servers.linear.env_http_headers] -existing = "keep" - -[mcp_servers.linear.http_headers] -alpha = "updated" -beta = "b" -"#, - )?; - assert_eq!(upserted, expected_upsert); - - std::fs::write(&path, base)?; - - service - .write_value(ConfigValueWriteParams { - file_path: Some(path.display().to_string()), - key_path: "mcp_servers.linear".to_string(), - value: overlay, - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect("replace succeeds"); - - let replaced: TomlValue = toml::from_str(&std::fs::read_to_string(&path)?)?; - let expected_replace: TomlValue = toml::from_str( - r#"[mcp_servers.linear] -bearer_token_env_var = "NEW_TOKEN" -name = "linear" -url = "https://linear.example" - -[mcp_servers.linear.http_headers] -alpha = "updated" -beta = "b" -"#, - )?; - assert_eq!(replaced, expected_replace); - - Ok(()) - } -} +#[path = "service_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/config/service_tests.rs b/codex-rs/core/src/config/service_tests.rs new file mode 100644 index 0000000000..a23537e139 --- /dev/null +++ b/codex-rs/core/src/config/service_tests.rs @@ -0,0 +1,682 @@ +use super::*; +use anyhow::Result; +use codex_app_server_protocol::AppConfig; +use codex_app_server_protocol::AppToolApproval; +use codex_app_server_protocol::AppsConfig; +use codex_app_server_protocol::AskForApproval; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use tempfile::tempdir; + +#[test] +fn toml_value_to_item_handles_nested_config_tables() { + let config = r#" +[mcp_servers.docs] +command = "docs-server" + +[mcp_servers.docs.http_headers] +X-Doc = "42" +"#; + + let value: TomlValue = toml::from_str(config).expect("parse config example"); + let item = toml_value_to_item(&value).expect("convert to toml_edit item"); + + let root = item.as_table().expect("root table"); + assert!(!root.is_implicit(), "root table should be explicit"); + + let mcp_servers = root + .get("mcp_servers") + .and_then(TomlItem::as_table) + .expect("mcp_servers table"); + assert!( + !mcp_servers.is_implicit(), + "mcp_servers table should be explicit" + ); + + let docs = mcp_servers + .get("docs") + .and_then(TomlItem::as_table) + .expect("docs table"); + assert_eq!( + docs.get("command") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("docs-server") + ); + + let http_headers = docs + .get("http_headers") + .and_then(TomlItem::as_table) + .expect("http_headers table"); + assert_eq!( + http_headers + .get("X-Doc") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("42") + ); +} + +#[tokio::test] +async fn write_value_preserves_comments_and_order() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let original = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +"#; + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), original)?; + + let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "features.personality".to_string(), + value: serde_json::json!(true), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let updated = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +personality = true +"#; + assert_eq!(updated, expected); + Ok(()) +} + +#[tokio::test] +async fn write_value_supports_nested_app_paths() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?; + + let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "apps".to_string(), + value: serde_json::json!({ + "app1": { + "enabled": false, + }, + }), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write apps succeeds"); + + service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "apps.app1.default_tools_approval_mode".to_string(), + value: serde_json::json!("prompt"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write apps.app1.default_tools_approval_mode succeeds"); + + let read = service + .read(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await + .expect("config read succeeds"); + + assert_eq!( + read.config.apps, + Some(AppsConfig { + default: None, + apps: std::collections::HashMap::from([( + "app1".to_string(), + AppConfig { + enabled: false, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: None, + }, + )]), + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn read_includes_origins_and_layers() { + let tmp = tempdir().expect("tempdir"); + let user_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&user_path, "model = \"user\"").unwrap(); + let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file"); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path.clone()), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::default(), + ); + + let response = service + .read(ConfigReadParams { + include_layers: true, + cwd: None, + }) + .await + .expect("response"); + + assert_eq!(response.config.approval_policy, Some(AskForApproval::Never)); + + assert_eq!( + response + .origins + .get("approval_policy") + .expect("origin") + .name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone() + }, + ); + let layers = response.layers.expect("layers present"); + // Local macOS machines can surface an MDM-managed config layer at the + // top of the stack; ignore it so this test stays focused on file/user/system ordering. + let layers = if matches!( + layers.first().map(|layer| &layer.name), + Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm) + ) { + &layers[1..] + } else { + layers.as_slice() + }; + assert_eq!(layers.len(), 3, "expected three layers"); + assert_eq!( + layers.first().unwrap().name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone() + } + ); + assert_eq!( + layers.get(1).unwrap().name, + ConfigLayerSource::User { + file: user_file.clone() + } + ); + assert!(matches!( + layers.get(2).unwrap().name, + ConfigLayerSource::System { .. } + )); +} + +#[tokio::test] +async fn write_value_reports_override() { + let tmp = tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + "approval_policy = \"on-request\"", + ) + .unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path.clone()), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::default(), + ); + + let result = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "approval_policy".to_string(), + value: serde_json::json!("never"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("result"); + + let read_after = service + .read(ConfigReadParams { + include_layers: true, + cwd: None, + }) + .await + .expect("read"); + assert_eq!( + read_after.config.approval_policy, + Some(AskForApproval::Never) + ); + assert_eq!( + read_after + .origins + .get("approval_policy") + .expect("origin") + .name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone() + } + ); + assert_eq!(result.status, WriteStatus::Ok); + assert!(result.overridden_metadata.is_none()); +} + +#[tokio::test] +async fn version_conflict_rejected() { + let tmp = tempdir().expect("tempdir"); + let user_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&user_path, "model = \"user\"").unwrap(); + + let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "model".to_string(), + value: serde_json::json!("gpt-5"), + merge_strategy: MergeStrategy::Replace, + expected_version: Some("sha256:bogus".to_string()), + }) + .await + .expect_err("should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigVersionConflict) + ); +} + +#[tokio::test] +async fn write_value_defaults_to_user_config_path() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + service + .write_value(ConfigValueWriteParams { + file_path: None, + key_path: "model".to_string(), + value: serde_json::json!("gpt-new"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert!( + contents.contains("model = \"gpt-new\""), + "config.toml should be updated even when file_path is omitted" + ); +} + +#[tokio::test] +async fn invalid_user_value_rejected_even_if_overridden_by_managed() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path.clone()), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::default(), + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "approval_policy".to_string(), + value: serde_json::json!("bogus"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("should fail validation"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + + let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents.trim(), "model = \"user\""); +} + +#[tokio::test] +async fn write_value_rejects_feature_requirement_conflict() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: None, + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::new(async { + Ok(Some(ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([("personality".to_string(), true)]), + }), + ..Default::default() + })) + }), + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "features.personality".to_string(), + value: serde_json::json!(false), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("conflicting feature write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("invalid value for `features`: `features.personality=false`"), + "{error}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(), + "" + ); +} + +#[tokio::test] +async fn write_value_rejects_profile_feature_requirement_conflict() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: None, + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::new(async { + Ok(Some(ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([("personality".to_string(), true)]), + }), + ..Default::default() + })) + }), + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "profiles.enterprise.features.personality".to_string(), + value: serde_json::json!(false), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("conflicting profile feature write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error.to_string().contains( + "invalid value for `features`: `profiles.enterprise.features.personality=false`" + ), + "{error}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(), + "" + ); +} + +#[tokio::test] +async fn read_reports_managed_overrides_user_and_session_flags() { + let tmp = tempdir().expect("tempdir"); + let user_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&user_path, "model = \"user\"").unwrap(); + let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file"); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "model = \"system\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); + + let cli_overrides = vec![( + "model".to_string(), + TomlValue::String("session".to_string()), + )]; + + let service = ConfigService::new( + tmp.path().to_path_buf(), + cli_overrides, + LoaderOverrides { + managed_config_path: Some(managed_path.clone()), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::default(), + ); + + let response = service + .read(ConfigReadParams { + include_layers: true, + cwd: None, + }) + .await + .expect("response"); + + assert_eq!(response.config.model.as_deref(), Some("system")); + assert_eq!( + response.origins.get("model").expect("origin").name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone() + }, + ); + let layers = response.layers.expect("layers"); + // Local macOS machines can surface an MDM-managed config layer at the + // top of the stack; ignore it so this test stays focused on file/session/user ordering. + let layers = if matches!( + layers.first().map(|layer| &layer.name), + Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm) + ) { + &layers[1..] + } else { + layers.as_slice() + }; + assert_eq!( + layers.first().unwrap().name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file } + ); + assert_eq!(layers.get(1).unwrap().name, ConfigLayerSource::SessionFlags); + assert_eq!( + layers.get(2).unwrap().name, + ConfigLayerSource::User { file: user_file } + ); +} + +#[tokio::test] +async fn write_value_reports_managed_override() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path.clone()), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::default(), + ); + + let result = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "approval_policy".to_string(), + value: serde_json::json!("on-request"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("result"); + + assert_eq!(result.status, WriteStatus::OkOverridden); + let overridden = result.overridden_metadata.expect("overridden metadata"); + assert_eq!( + overridden.overriding_layer.name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file } + ); + assert_eq!(overridden.effective_value, serde_json::json!("never")); +} + +#[tokio::test] +async fn upsert_merges_tables_replace_overwrites() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + let base = r#"[mcp_servers.linear] +bearer_token_env_var = "TOKEN" +name = "linear" +url = "https://linear.example" + +[mcp_servers.linear.env_http_headers] +existing = "keep" + +[mcp_servers.linear.http_headers] +alpha = "a" +"#; + + let overlay = serde_json::json!({ + "bearer_token_env_var": "NEW_TOKEN", + "http_headers": { + "alpha": "updated", + "beta": "b" + }, + "name": "linear", + "url": "https://linear.example" + }); + + std::fs::write(&path, base)?; + + let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "mcp_servers.linear".to_string(), + value: overlay.clone(), + merge_strategy: MergeStrategy::Upsert, + expected_version: None, + }) + .await + .expect("upsert succeeds"); + + let upserted: TomlValue = toml::from_str(&std::fs::read_to_string(&path)?)?; + let expected_upsert: TomlValue = toml::from_str( + r#"[mcp_servers.linear] +bearer_token_env_var = "NEW_TOKEN" +name = "linear" +url = "https://linear.example" + +[mcp_servers.linear.env_http_headers] +existing = "keep" + +[mcp_servers.linear.http_headers] +alpha = "updated" +beta = "b" +"#, + )?; + assert_eq!(upserted, expected_upsert); + + std::fs::write(&path, base)?; + + service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "mcp_servers.linear".to_string(), + value: overlay, + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("replace succeeds"); + + let replaced: TomlValue = toml::from_str(&std::fs::read_to_string(&path)?)?; + let expected_replace: TomlValue = toml::from_str( + r#"[mcp_servers.linear] +bearer_token_env_var = "NEW_TOKEN" +name = "linear" +url = "https://linear.example" + +[mcp_servers.linear.http_headers] +alpha = "updated" +beta = "b" +"#, + )?; + assert_eq!(replaced, expected_replace); + + Ok(()) +} diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index d3e3542c57..68ef2a630e 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -940,320 +940,5 @@ impl Default for ShellEnvironmentPolicy { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn deserialize_stdio_command_server_config() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - "#, - ) - .expect("should deserialize command config"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::Stdio { - command: "echo".to_string(), - args: vec![], - env: None, - env_vars: Vec::new(), - cwd: None, - } - ); - assert!(cfg.enabled); - assert!(!cfg.required); - assert!(cfg.enabled_tools.is_none()); - assert!(cfg.disabled_tools.is_none()); - } - - #[test] - fn deserialize_stdio_command_server_config_with_args() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - args = ["hello", "world"] - "#, - ) - .expect("should deserialize command config"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::Stdio { - command: "echo".to_string(), - args: vec!["hello".to_string(), "world".to_string()], - env: None, - env_vars: Vec::new(), - cwd: None, - } - ); - assert!(cfg.enabled); - } - - #[test] - fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - args = ["hello", "world"] - env = { "FOO" = "BAR" } - "#, - ) - .expect("should deserialize command config"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::Stdio { - command: "echo".to_string(), - args: vec!["hello".to_string(), "world".to_string()], - env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])), - env_vars: Vec::new(), - cwd: None, - } - ); - assert!(cfg.enabled); - } - - #[test] - fn deserialize_stdio_command_server_config_with_env_vars() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - env_vars = ["FOO", "BAR"] - "#, - ) - .expect("should deserialize command config with env_vars"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::Stdio { - command: "echo".to_string(), - args: vec![], - env: None, - env_vars: vec!["FOO".to_string(), "BAR".to_string()], - cwd: None, - } - ); - } - - #[test] - fn deserialize_stdio_command_server_config_with_cwd() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - cwd = "/tmp" - "#, - ) - .expect("should deserialize command config with cwd"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::Stdio { - command: "echo".to_string(), - args: vec![], - env: None, - env_vars: Vec::new(), - cwd: Some(PathBuf::from("/tmp")), - } - ); - } - - #[test] - fn deserialize_disabled_server_config() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - enabled = false - "#, - ) - .expect("should deserialize disabled server config"); - - assert!(!cfg.enabled); - assert!(!cfg.required); - } - - #[test] - fn deserialize_required_server_config() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - required = true - "#, - ) - .expect("should deserialize required server config"); - - assert!(cfg.required); - } - - #[test] - fn deserialize_streamable_http_server_config() { - let cfg: McpServerConfig = toml::from_str( - r#" - url = "https://example.com/mcp" - "#, - ) - .expect("should deserialize http config"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::StreamableHttp { - url: "https://example.com/mcp".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - } - ); - assert!(cfg.enabled); - } - - #[test] - fn deserialize_streamable_http_server_config_with_env_var() { - let cfg: McpServerConfig = toml::from_str( - r#" - url = "https://example.com/mcp" - bearer_token_env_var = "GITHUB_TOKEN" - "#, - ) - .expect("should deserialize http config"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::StreamableHttp { - url: "https://example.com/mcp".to_string(), - bearer_token_env_var: Some("GITHUB_TOKEN".to_string()), - http_headers: None, - env_http_headers: None, - } - ); - assert!(cfg.enabled); - } - - #[test] - fn deserialize_streamable_http_server_config_with_headers() { - let cfg: McpServerConfig = toml::from_str( - r#" - url = "https://example.com/mcp" - http_headers = { "X-Foo" = "bar" } - env_http_headers = { "X-Token" = "TOKEN_ENV" } - "#, - ) - .expect("should deserialize http config with headers"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::StreamableHttp { - url: "https://example.com/mcp".to_string(), - bearer_token_env_var: None, - http_headers: Some(HashMap::from([("X-Foo".to_string(), "bar".to_string())])), - env_http_headers: Some(HashMap::from([( - "X-Token".to_string(), - "TOKEN_ENV".to_string() - )])), - } - ); - } - - #[test] - fn deserialize_streamable_http_server_config_with_oauth_resource() { - let cfg: McpServerConfig = toml::from_str( - r#" - url = "https://example.com/mcp" - oauth_resource = "https://api.example.com" - "#, - ) - .expect("should deserialize http config with oauth_resource"); - - assert_eq!( - cfg.oauth_resource, - Some("https://api.example.com".to_string()) - ); - } - - #[test] - fn deserialize_server_config_with_tool_filters() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - enabled_tools = ["allowed"] - disabled_tools = ["blocked"] - "#, - ) - .expect("should deserialize tool filters"); - - assert_eq!(cfg.enabled_tools, Some(vec!["allowed".to_string()])); - assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()])); - } - - #[test] - fn deserialize_rejects_command_and_url() { - toml::from_str::( - r#" - command = "echo" - url = "https://example.com" - "#, - ) - .expect_err("should reject command+url"); - } - - #[test] - fn deserialize_rejects_env_for_http_transport() { - toml::from_str::( - r#" - url = "https://example.com" - env = { "FOO" = "BAR" } - "#, - ) - .expect_err("should reject env for http transport"); - } - - #[test] - fn deserialize_rejects_headers_for_stdio() { - toml::from_str::( - r#" - command = "echo" - http_headers = { "X-Foo" = "bar" } - "#, - ) - .expect_err("should reject http_headers for stdio transport"); - - toml::from_str::( - r#" - command = "echo" - env_http_headers = { "X-Foo" = "BAR_ENV" } - "#, - ) - .expect_err("should reject env_http_headers for stdio transport"); - - let err = toml::from_str::( - r#" - command = "echo" - oauth_resource = "https://api.example.com" - "#, - ) - .expect_err("should reject oauth_resource for stdio transport"); - - assert!( - err.to_string() - .contains("oauth_resource is not supported for stdio"), - "unexpected error: {err}" - ); - } - - #[test] - fn deserialize_rejects_inline_bearer_token_field() { - let err = toml::from_str::( - r#" - url = "https://example.com" - bearer_token = "secret" - "#, - ) - .expect_err("should reject bearer_token field"); - - assert!( - err.to_string().contains("bearer_token is not supported"), - "unexpected error: {err}" - ); - } -} +#[path = "types_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/config/types_tests.rs b/codex-rs/core/src/config/types_tests.rs new file mode 100644 index 0000000000..adb65e1673 --- /dev/null +++ b/codex-rs/core/src/config/types_tests.rs @@ -0,0 +1,315 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn deserialize_stdio_command_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); + assert!(!cfg.required); + assert!(cfg.enabled_tools.is_none()); + assert!(cfg.disabled_tools.is_none()); +} + +#[test] +fn deserialize_stdio_command_server_config_with_args() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + args = ["hello", "world"] + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string(), "world".to_string()], + env: None, + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + args = ["hello", "world"] + env = { "FOO" = "BAR" } + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string(), "world".to_string()], + env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])), + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_stdio_command_server_config_with_env_vars() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + env_vars = ["FOO", "BAR"] + "#, + ) + .expect("should deserialize command config with env_vars"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: vec!["FOO".to_string(), "BAR".to_string()], + cwd: None, + } + ); +} + +#[test] +fn deserialize_stdio_command_server_config_with_cwd() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + cwd = "/tmp" + "#, + ) + .expect("should deserialize command config with cwd"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: Some(PathBuf::from("/tmp")), + } + ); +} + +#[test] +fn deserialize_disabled_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + enabled = false + "#, + ) + .expect("should deserialize disabled server config"); + + assert!(!cfg.enabled); + assert!(!cfg.required); +} + +#[test] +fn deserialize_required_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + required = true + "#, + ) + .expect("should deserialize required server config"); + + assert!(cfg.required); +} + +#[test] +fn deserialize_streamable_http_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + "#, + ) + .expect("should deserialize http config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_streamable_http_server_config_with_env_var() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + bearer_token_env_var = "GITHUB_TOKEN" + "#, + ) + .expect("should deserialize http config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("GITHUB_TOKEN".to_string()), + http_headers: None, + env_http_headers: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_streamable_http_server_config_with_headers() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + http_headers = { "X-Foo" = "bar" } + env_http_headers = { "X-Token" = "TOKEN_ENV" } + "#, + ) + .expect("should deserialize http config with headers"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: Some(HashMap::from([("X-Foo".to_string(), "bar".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Token".to_string(), + "TOKEN_ENV".to_string() + )])), + } + ); +} + +#[test] +fn deserialize_streamable_http_server_config_with_oauth_resource() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + oauth_resource = "https://api.example.com" + "#, + ) + .expect("should deserialize http config with oauth_resource"); + + assert_eq!( + cfg.oauth_resource, + Some("https://api.example.com".to_string()) + ); +} + +#[test] +fn deserialize_server_config_with_tool_filters() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + enabled_tools = ["allowed"] + disabled_tools = ["blocked"] + "#, + ) + .expect("should deserialize tool filters"); + + assert_eq!(cfg.enabled_tools, Some(vec!["allowed".to_string()])); + assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()])); +} + +#[test] +fn deserialize_rejects_command_and_url() { + toml::from_str::( + r#" + command = "echo" + url = "https://example.com" + "#, + ) + .expect_err("should reject command+url"); +} + +#[test] +fn deserialize_rejects_env_for_http_transport() { + toml::from_str::( + r#" + url = "https://example.com" + env = { "FOO" = "BAR" } + "#, + ) + .expect_err("should reject env for http transport"); +} + +#[test] +fn deserialize_rejects_headers_for_stdio() { + toml::from_str::( + r#" + command = "echo" + http_headers = { "X-Foo" = "bar" } + "#, + ) + .expect_err("should reject http_headers for stdio transport"); + + toml::from_str::( + r#" + command = "echo" + env_http_headers = { "X-Foo" = "BAR_ENV" } + "#, + ) + .expect_err("should reject env_http_headers for stdio transport"); + + let err = toml::from_str::( + r#" + command = "echo" + oauth_resource = "https://api.example.com" + "#, + ) + .expect_err("should reject oauth_resource for stdio transport"); + + assert!( + err.to_string() + .contains("oauth_resource is not supported for stdio"), + "unexpected error: {err}" + ); +} + +#[test] +fn deserialize_rejects_inline_bearer_token_field() { + let err = toml::from_str::( + r#" + url = "https://example.com" + bearer_token = "secret" + "#, + ) + .expect_err("should reject bearer_token field"); + + assert!( + err.to_string().contains("bearer_token is not supported"), + "unexpected error: {err}" + ); +} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index b2b8ac109e..cced54174a 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -9,13 +9,17 @@ use std::sync::Mutex as StdMutex; use std::time::Duration; use std::time::Instant; +use anyhow::Context; use async_channel::unbounded; pub use codex_app_server_protocol::AppBranding; pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; +use codex_connectors::AllConnectorsCacheKey; +use codex_connectors::DirectoryListResponse; use codex_protocol::protocol::SandboxPolicy; use rmcp::model::ToolAnnotations; use serde::Deserialize; +use serde::de::DeserializeOwned; use tracing::warn; use crate::AuthManager; @@ -24,6 +28,7 @@ use crate::SandboxState; use crate::config::Config; use crate::config::types::AppToolApproval; use crate::config::types::AppsConfigToml; +use crate::default_client::create_client; use crate::default_client::is_first_party_chat_originator; use crate::default_client::originator; use crate::features::Feature; @@ -38,8 +43,22 @@ use crate::plugins::AppConnectorId; use crate::plugins::PluginsManager; use crate::token_data::TokenData; -pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); +pub use codex_connectors::CONNECTORS_CACHE_TTL; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); +const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); +const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[ + "connector_2128aebfecb84f64a069897515042a44", + "connector_68df038e0ba48191908c8434991bbac2", + "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "connector_4964e3b22e3e427e9b4ae1acf2c1fa34", + "connector_9d7cfa34e6654a5f98d3387af34b2e1c", + "connector_6f1ec045b8fa4ced8738e32c7f74514b", + "connector_947e0d954944416db111db556030eea6", + "connector_5f3c8c41a1e54ad7a76272c89e2554fa", + "connector_686fad9b54914a35b75be6d06a0f6f31", + "connector_76869538009648d5b282a4bb21c3d157", + "connector_37316be7febe4224b3d31465bae4dbd7", +]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AppToolPolicy { @@ -90,6 +109,19 @@ pub async fn list_accessible_connectors_from_mcp_tools( ) } +pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( + config: &Config, + auth: Option<&CodexAuth>, + accessible_connectors: &[AppInfo], +) -> anyhow::Result> { + let directory_connectors = + list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?; + Ok(filter_tool_suggest_discoverable_tools( + directory_connectors, + accessible_connectors, + )) +} + pub async fn list_cached_accessible_connectors_from_mcp_tools( config: &Config, ) -> Option> { @@ -102,6 +134,21 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools( read_cached_accessible_connectors(&cache_key).map(filter_disallowed_connectors) } +pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( + config: &Config, + auth: Option<&CodexAuth>, + mcp_tools: &HashMap, +) { + if !config.features.enabled(Feature::Apps) { + return; + } + + let cache_key = accessible_connectors_cache_key(config, auth); + let accessible_connectors = + filter_disallowed_connectors(accessible_connectors_from_mcp_tools(mcp_tools)); + write_cached_accessible_connectors(cache_key, &accessible_connectors); +} + pub async fn list_accessible_connectors_from_mcp_tools_with_options( config: &Config, force_refetch: bool, @@ -156,7 +203,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( sandbox_policy: SandboxPolicy::new_read_only_policy(), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), - use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap), + use_legacy_landlock: config.features.use_legacy_landlock(), }; let (mcp_connection_manager, cancel_token) = McpConnectionManager::new( @@ -172,19 +219,33 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( ) .await; - if force_refetch - && let Err(err) = mcp_connection_manager + let refreshed_tools = if force_refetch { + match mcp_connection_manager .hard_refresh_codex_apps_tools_cache() .await - { - warn!( - "failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}" - ); - } + { + Ok(tools) => Some(tools), + Err(err) => { + warn!( + "failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}" + ); + None + } + } + } else { + None + }; + let refreshed_tools_succeeded = refreshed_tools.is_some(); - let mut tools = mcp_connection_manager.list_all_tools().await; + let mut tools = if let Some(tools) = refreshed_tools { + tools + } else { + mcp_connection_manager.list_all_tools().await + }; let mut should_reload_tools = false; - let codex_apps_ready = if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) { + let codex_apps_ready = if refreshed_tools_succeeded { + true + } else if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) { let immediate_ready = mcp_connection_manager .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO) .await; @@ -281,6 +342,119 @@ fn write_cached_accessible_connectors( }); } +fn filter_tool_suggest_discoverable_tools( + directory_connectors: Vec, + accessible_connectors: &[AppInfo], +) -> Vec { + let accessible_connector_ids: HashSet<&str> = accessible_connectors + .iter() + .filter(|connector| connector.is_accessible && connector.is_enabled) + .map(|connector| connector.id.as_str()) + .collect(); + let allowed_connector_ids: HashSet<&str> = TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS + .iter() + .copied() + .collect(); + + let mut connectors = filter_disallowed_connectors(directory_connectors) + .into_iter() + .filter(|connector| !accessible_connector_ids.contains(connector.id.as_str())) + .filter(|connector| allowed_connector_ids.contains(connector.id.as_str())) + .collect::>(); + connectors.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + connectors +} + +async fn list_directory_connectors_for_tool_suggest_with_auth( + config: &Config, + auth: Option<&CodexAuth>, +) -> anyhow::Result> { + if !config.features.enabled(Feature::Apps) { + return Ok(Vec::new()); + } + + let token_data = if let Some(auth) = auth { + auth.get_token_data().ok() + } else { + let auth_manager = auth_manager_from_config(config); + auth_manager + .auth() + .await + .and_then(|auth| auth.get_token_data().ok()) + }; + let Some(token_data) = token_data else { + return Ok(Vec::new()); + }; + + let account_id = match token_data.account_id.as_deref() { + Some(account_id) if !account_id.is_empty() => account_id, + _ => return Ok(Vec::new()), + }; + let access_token = token_data.access_token.clone(); + let account_id = account_id.to_string(); + let is_workspace_account = token_data.id_token.is_workspace_account(); + let cache_key = AllConnectorsCacheKey::new( + config.chatgpt_base_url.clone(), + Some(account_id.clone()), + token_data.id_token.chatgpt_user_id.clone(), + is_workspace_account, + ); + + codex_connectors::list_all_connectors_with_options( + cache_key, + is_workspace_account, + false, + |path| { + let access_token = access_token.clone(); + let account_id = account_id.clone(); + async move { + chatgpt_get_request_with_token::( + config, + path, + access_token.as_str(), + account_id.as_str(), + ) + .await + } + }, + ) + .await +} + +async fn chatgpt_get_request_with_token( + config: &Config, + path: String, + access_token: &str, + account_id: &str, +) -> anyhow::Result { + let client = create_client(); + let url = format!("{}{}", config.chatgpt_base_url, path); + let response = client + .get(&url) + .bearer_auth(access_token) + .header("chatgpt-account-id", account_id) + .header("Content-Type", "application/json") + .timeout(DIRECTORY_CONNECTORS_TIMEOUT) + .send() + .await + .context("failed to send request")?; + + if response.status().is_success() { + response + .json() + .await + .context("failed to parse JSON response") + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("request failed with status {status}: {body}"); + } +} + fn auth_manager_from_config(config: &Config) -> std::sync::Arc { AuthManager::shared( config.codex_home.clone(), @@ -294,7 +468,7 @@ pub fn connector_display_label(connector: &AppInfo) -> String { } pub fn connector_mention_slug(connector: &AppInfo) -> String { - connector_name_slug(&connector_display_label(connector)) + sanitize_name(&connector_display_label(connector)) } pub(crate) fn accessible_connectors_from_mcp_tools( @@ -307,10 +481,10 @@ pub(crate) fn accessible_connectors_from_mcp_tools( return None; } let connector_id = tool.connector_id.as_deref()?; - let connector_name = normalize_connector_value(tool.connector_name.as_deref()); Some(( connector_id.to_string(), - connector_name, + normalize_connector_value(tool.connector_name.as_deref()), + normalize_connector_value(tool.connector_description.as_deref()), tool.plugin_display_names.clone(), )) }); @@ -467,21 +641,13 @@ pub(crate) fn codex_app_tool_is_enabled( app_tool_policy( config, tool_info.connector_id.as_deref(), - &tool_info.tool_name, + &tool_info.tool.name, tool_info.tool.title.as_deref(), tool_info.tool.annotations.as_ref(), ) .enabled } -pub(crate) fn filter_codex_apps_tools_by_policy( - mut mcp_tools: HashMap, - config: &Config, -) -> HashMap { - mcp_tools.retain(|_, tool_info| codex_app_tool_is_enabled(config, tool_info)); - mcp_tools -} - const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ "asdk_app_6938a94a61d881918ef32cb999ff937c", "connector_2b0a9009c9c64bf9933a3dae3f2b1254", @@ -611,23 +777,38 @@ fn app_tool_policy_from_apps_config( fn collect_accessible_connectors(tools: I) -> Vec where - I: IntoIterator, Vec)>, + I: IntoIterator, Option, Vec)>, { - let mut connectors: HashMap)> = HashMap::new(); - for (connector_id, connector_name, plugin_display_names) in tools { + let mut connectors: HashMap)> = HashMap::new(); + for (connector_id, connector_name, connector_description, plugin_display_names) in tools { let connector_name = connector_name.unwrap_or_else(|| connector_id.clone()); - if let Some((existing_name, existing_plugin_display_names)) = - connectors.get_mut(&connector_id) - { - if existing_name == &connector_id && connector_name != connector_id { - *existing_name = connector_name; + if let Some((existing, existing_plugin_display_names)) = connectors.get_mut(&connector_id) { + if existing.name == connector_id && connector_name != connector_id { + existing.name = connector_name; + } + if existing.description.is_none() && connector_description.is_some() { + existing.description = connector_description; } existing_plugin_display_names.extend(plugin_display_names); } else { connectors.insert( - connector_id, + connector_id.clone(), ( - connector_name, + AppInfo { + id: connector_id.clone(), + name: connector_name, + description: connector_description, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }, plugin_display_names .into_iter() .collect::>(), @@ -636,24 +817,12 @@ where } } let mut accessible: Vec = connectors - .into_iter() - .map( - |(connector_id, (connector_name, plugin_display_names))| AppInfo { - id: connector_id.clone(), - name: connector_name.clone(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some(connector_install_url(&connector_name, &connector_id)), - is_accessible: true, - is_enabled: true, - plugin_display_names: plugin_display_names.into_iter().collect(), - }, - ) + .into_values() + .map(|(mut connector, plugin_display_names)| { + connector.plugin_display_names = plugin_display_names.into_iter().collect(); + connector.install_url = Some(connector_install_url(&connector.name, &connector.id)); + connector + }) .collect(); accessible.sort_by(|left, right| { right @@ -696,11 +865,11 @@ fn normalize_connector_value(value: Option<&str>) -> Option { } pub fn connector_install_url(name: &str, connector_id: &str) -> String { - let slug = connector_name_slug(name); + let slug = sanitize_name(name); format!("https://chatgpt.com/apps/{slug}/{connector_id}") } -pub fn connector_name_slug(name: &str) -> String { +pub fn sanitize_name(name: &str) -> String { let mut normalized = String::with_capacity(name.len()); for character in name.chars() { if character.is_ascii_alphanumeric() { @@ -722,574 +891,5 @@ fn format_connector_label(name: &str, _id: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - use crate::config::types::AppConfig; - use crate::config::types::AppToolConfig; - use crate::config::types::AppToolsConfig; - use crate::config::types::AppsDefaultConfig; - use crate::mcp_connection_manager::ToolInfo; - use pretty_assertions::assert_eq; - use rmcp::model::JsonObject; - use rmcp::model::Tool; - use std::sync::Arc; - - fn annotations( - destructive_hint: Option, - open_world_hint: Option, - ) -> ToolAnnotations { - ToolAnnotations { - destructive_hint, - idempotent_hint: None, - open_world_hint, - read_only_hint: None, - title: None, - } - } - - fn app(id: &str) -> AppInfo { - AppInfo { - id: id.to_string(), - name: id.to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - install_url: None, - branding: None, - app_metadata: None, - labels: None, - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - } - } - - fn plugin_names(names: &[&str]) -> Vec { - names.iter().map(ToString::to_string).collect() - } - - fn test_tool_definition(tool_name: &str) -> Tool { - Tool { - name: tool_name.to_string().into(), - title: None, - description: None, - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - } - } - - fn google_calendar_accessible_connector(plugin_display_names: &[&str]) -> AppInfo { - AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: Some("Plan events".to_string()), - logo_url: Some("https://example.com/logo.png".to_string()), - logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), - distribution_channel: Some("workspace".to_string()), - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: true, - is_enabled: true, - plugin_display_names: plugin_names(plugin_display_names), - } - } - - fn codex_app_tool( - tool_name: &str, - connector_id: &str, - connector_name: Option<&str>, - plugin_display_names: &[&str], - ) -> ToolInfo { - ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool_name: tool_name.to_string(), - tool: test_tool_definition(tool_name), - connector_id: Some(connector_id.to_string()), - connector_name: connector_name.map(ToOwned::to_owned), - plugin_display_names: plugin_names(plugin_display_names), - } - } - - #[test] - fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { - let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); - let accessible = google_calendar_accessible_connector(&[]); - - let merged = merge_connectors(vec![plugin], vec![accessible]); - - assert_eq!( - merged, - vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: Some("Plan events".to_string()), - logo_url: Some("https://example.com/logo.png".to_string()), - logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), - distribution_channel: Some("workspace".to_string()), - branding: None, - app_metadata: None, - labels: None, - install_url: Some(connector_install_url("calendar", "calendar")), - is_accessible: true, - is_enabled: true, - plugin_display_names: Vec::new(), - }] - ); - assert_eq!(connector_mention_slug(&merged[0]), "google-calendar"); - } - - #[test] - fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { - let tools = HashMap::from([ - ( - "mcp__codex_apps__calendar_list_events".to_string(), - codex_app_tool( - "calendar_list_events", - "calendar", - None, - &["sample", "sample"], - ), - ), - ( - "mcp__codex_apps__calendar_create_event".to_string(), - codex_app_tool( - "calendar_create_event", - "calendar", - Some("Google Calendar"), - &["beta", "sample"], - ), - ), - ( - "mcp__sample__echo".to_string(), - ToolInfo { - server_name: "sample".to_string(), - tool_name: "echo".to_string(), - tool: test_tool_definition("echo"), - connector_id: None, - connector_name: None, - plugin_display_names: plugin_names(&["ignored"]), - }, - ), - ]); - - let connectors = accessible_connectors_from_mcp_tools(&tools); - - assert_eq!( - connectors, - vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - install_url: Some(connector_install_url("Google Calendar", "calendar")), - branding: None, - app_metadata: None, - labels: None, - is_accessible: true, - is_enabled: true, - plugin_display_names: plugin_names(&["beta", "sample"]), - }] - ); - } - - #[test] - fn merge_connectors_unions_and_dedupes_plugin_display_names() { - let mut plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); - plugin.plugin_display_names = plugin_names(&["sample", "alpha", "sample"]); - - let accessible = google_calendar_accessible_connector(&["beta", "alpha"]); - - let merged = merge_connectors(vec![plugin], vec![accessible]); - - assert_eq!( - merged, - vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: Some("Plan events".to_string()), - logo_url: Some("https://example.com/logo.png".to_string()), - logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), - distribution_channel: Some("workspace".to_string()), - branding: None, - app_metadata: None, - labels: None, - install_url: Some(connector_install_url("calendar", "calendar")), - is_accessible: true, - is_enabled: true, - plugin_display_names: plugin_names(&["alpha", "beta", "sample"]), - }] - ); - } - - #[test] - fn app_tool_policy_uses_global_defaults_for_destructive_hints() { - let apps_config = AppsConfigToml { - default: Some(AppsDefaultConfig { - enabled: true, - destructive_enabled: false, - open_world_enabled: true, - }), - apps: HashMap::new(), - }; - - let policy = app_tool_policy_from_apps_config( - Some(&apps_config), - Some("calendar"), - "events/create", - None, - Some(&annotations(Some(true), None)), - ); - - assert_eq!( - policy, - AppToolPolicy { - enabled: false, - approval: AppToolApproval::Auto, - } - ); - } - - #[test] - fn app_is_enabled_uses_default_for_unconfigured_apps() { - let apps_config = AppsConfigToml { - default: Some(AppsDefaultConfig { - enabled: false, - destructive_enabled: true, - open_world_enabled: true, - }), - apps: HashMap::new(), - }; - - assert!(!app_is_enabled(&apps_config, Some("calendar"))); - assert!(!app_is_enabled(&apps_config, None)); - } - - #[test] - fn app_is_enabled_prefers_per_app_override_over_default() { - let apps_config = AppsConfigToml { - default: Some(AppsDefaultConfig { - enabled: false, - destructive_enabled: true, - open_world_enabled: true, - }), - apps: HashMap::from([( - "calendar".to_string(), - AppConfig { - enabled: true, - destructive_enabled: None, - open_world_enabled: None, - default_tools_approval_mode: None, - default_tools_enabled: None, - tools: None, - }, - )]), - }; - - assert!(app_is_enabled(&apps_config, Some("calendar"))); - assert!(!app_is_enabled(&apps_config, Some("drive"))); - } - - #[test] - fn app_tool_policy_honors_default_app_enabled_false() { - let apps_config = AppsConfigToml { - default: Some(AppsDefaultConfig { - enabled: false, - destructive_enabled: true, - open_world_enabled: true, - }), - apps: HashMap::new(), - }; - - let policy = app_tool_policy_from_apps_config( - Some(&apps_config), - Some("calendar"), - "events/list", - None, - Some(&annotations(None, None)), - ); - - assert_eq!( - policy, - AppToolPolicy { - enabled: false, - approval: AppToolApproval::Auto, - } - ); - } - - #[test] - fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() { - let apps_config = AppsConfigToml { - default: Some(AppsDefaultConfig { - enabled: false, - destructive_enabled: true, - open_world_enabled: true, - }), - apps: HashMap::from([( - "calendar".to_string(), - AppConfig { - enabled: true, - destructive_enabled: None, - open_world_enabled: None, - default_tools_approval_mode: None, - default_tools_enabled: None, - tools: None, - }, - )]), - }; - - let policy = app_tool_policy_from_apps_config( - Some(&apps_config), - Some("calendar"), - "events/list", - None, - Some(&annotations(None, None)), - ); - - assert_eq!( - policy, - AppToolPolicy { - enabled: true, - approval: AppToolApproval::Auto, - } - ); - } - - #[test] - fn app_tool_policy_per_tool_enabled_true_overrides_app_level_disable_flags() { - let apps_config = AppsConfigToml { - default: None, - apps: HashMap::from([( - "calendar".to_string(), - AppConfig { - enabled: true, - destructive_enabled: Some(false), - open_world_enabled: Some(false), - default_tools_approval_mode: None, - default_tools_enabled: None, - tools: Some(AppToolsConfig { - tools: HashMap::from([( - "events/create".to_string(), - AppToolConfig { - enabled: Some(true), - approval_mode: None, - }, - )]), - }), - }, - )]), - }; - - let policy = app_tool_policy_from_apps_config( - Some(&apps_config), - Some("calendar"), - "events/create", - None, - Some(&annotations(Some(true), Some(true))), - ); - - assert_eq!( - policy, - AppToolPolicy { - enabled: true, - approval: AppToolApproval::Auto, - } - ); - } - - #[test] - fn app_tool_policy_default_tools_enabled_true_overrides_app_level_tool_hints() { - let apps_config = AppsConfigToml { - default: None, - apps: HashMap::from([( - "calendar".to_string(), - AppConfig { - enabled: true, - destructive_enabled: Some(false), - open_world_enabled: Some(false), - default_tools_approval_mode: None, - default_tools_enabled: Some(true), - tools: None, - }, - )]), - }; - - let policy = app_tool_policy_from_apps_config( - Some(&apps_config), - Some("calendar"), - "events/create", - None, - Some(&annotations(Some(true), Some(true))), - ); - - assert_eq!( - policy, - AppToolPolicy { - enabled: true, - approval: AppToolApproval::Auto, - } - ); - } - - #[test] - fn app_tool_policy_default_tools_enabled_false_overrides_app_level_tool_hints() { - let apps_config = AppsConfigToml { - default: None, - apps: HashMap::from([( - "calendar".to_string(), - AppConfig { - enabled: true, - destructive_enabled: Some(true), - open_world_enabled: Some(true), - default_tools_approval_mode: Some(AppToolApproval::Approve), - default_tools_enabled: Some(false), - tools: None, - }, - )]), - }; - - let policy = app_tool_policy_from_apps_config( - Some(&apps_config), - Some("calendar"), - "events/list", - None, - Some(&annotations(None, None)), - ); - - assert_eq!( - policy, - AppToolPolicy { - enabled: false, - approval: AppToolApproval::Approve, - } - ); - } - - #[test] - fn app_tool_policy_uses_default_tools_approval_mode() { - let apps_config = AppsConfigToml { - default: None, - apps: HashMap::from([( - "calendar".to_string(), - AppConfig { - enabled: true, - destructive_enabled: None, - open_world_enabled: None, - default_tools_approval_mode: Some(AppToolApproval::Prompt), - default_tools_enabled: None, - tools: Some(AppToolsConfig { - tools: HashMap::new(), - }), - }, - )]), - }; - - let policy = app_tool_policy_from_apps_config( - Some(&apps_config), - Some("calendar"), - "events/list", - None, - Some(&annotations(None, None)), - ); - - assert_eq!( - policy, - AppToolPolicy { - enabled: true, - approval: AppToolApproval::Prompt, - } - ); - } - - #[test] - fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() { - let apps_config = AppsConfigToml { - default: None, - apps: HashMap::from([( - "calendar".to_string(), - AppConfig { - enabled: true, - destructive_enabled: Some(false), - open_world_enabled: Some(false), - default_tools_approval_mode: Some(AppToolApproval::Auto), - default_tools_enabled: Some(false), - tools: Some(AppToolsConfig { - tools: HashMap::from([( - "events/create".to_string(), - AppToolConfig { - enabled: Some(true), - approval_mode: Some(AppToolApproval::Approve), - }, - )]), - }), - }, - )]), - }; - - let policy = app_tool_policy_from_apps_config( - Some(&apps_config), - Some("calendar"), - "calendar_events/create", - Some("events/create"), - Some(&annotations(Some(true), Some(true))), - ); - - assert_eq!( - policy, - AppToolPolicy { - enabled: true, - approval: AppToolApproval::Approve, - } - ); - } - - #[test] - fn filter_disallowed_connectors_allows_non_disallowed_connectors() { - let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]); - assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]); - } - - #[test] - fn filter_disallowed_connectors_filters_openai_prefix() { - let filtered = filter_disallowed_connectors(vec![ - app("connector_openai_foo"), - app("connector_openai_bar"), - app("gamma"), - ]); - assert_eq!(filtered, vec![app("gamma")]); - } - - #[test] - fn filter_disallowed_connectors_filters_disallowed_connector_ids() { - let filtered = filter_disallowed_connectors(vec![ - app("asdk_app_6938a94a61d881918ef32cb999ff937c"), - app("delta"), - ]); - assert_eq!(filtered, vec![app("delta")]); - } - - #[test] - fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() { - let filtered = filter_disallowed_connectors_for_originator( - vec![ - app("connector_openai_foo"), - app("asdk_app_6938a94a61d881918ef32cb999ff937c"), - app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"), - ], - "codex_atlas", - ); - assert_eq!( - filtered, - vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")] - ); - } -} +#[path = "connectors_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs new file mode 100644 index 0000000000..3743fb9f87 --- /dev/null +++ b/codex-rs/core/src/connectors_tests.rs @@ -0,0 +1,770 @@ +use super::*; +use crate::config::ConfigBuilder; +use crate::config::types::AppConfig; +use crate::config::types::AppToolConfig; +use crate::config::types::AppToolsConfig; +use crate::config::types::AppsDefaultConfig; +use crate::features::Feature; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp_connection_manager::ToolInfo; +use pretty_assertions::assert_eq; +use rmcp::model::JsonObject; +use rmcp::model::Tool; +use std::collections::HashMap; +use std::sync::Arc; +use tempfile::tempdir; + +fn annotations(destructive_hint: Option, open_world_hint: Option) -> ToolAnnotations { + ToolAnnotations { + destructive_hint, + idempotent_hint: None, + open_world_hint, + read_only_hint: None, + title: None, + } +} + +fn app(id: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: None, + branding: None, + app_metadata: None, + labels: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + } +} + +fn named_app(id: &str, name: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: name.to_string(), + install_url: Some(connector_install_url(name, id)), + ..app(id) + } +} + +fn plugin_names(names: &[&str]) -> Vec { + names.iter().map(ToString::to_string).collect() +} + +fn test_tool_definition(tool_name: &str) -> Tool { + Tool { + name: tool_name.to_string().into(), + title: None, + description: None, + input_schema: Arc::new(JsonObject::default()), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + } +} + +fn google_calendar_accessible_connector(plugin_display_names: &[&str]) -> AppInfo { + AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(plugin_display_names), + } +} + +fn codex_app_tool( + tool_name: &str, + connector_id: &str, + connector_name: Option<&str>, + plugin_display_names: &[&str], +) -> ToolInfo { + let tool_namespace = connector_name + .map(sanitize_name) + .map(|connector_name| format!("mcp__{CODEX_APPS_MCP_SERVER_NAME}__{connector_name}")) + .unwrap_or_else(|| CODEX_APPS_MCP_SERVER_NAME.to_string()); + + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: tool_name.to_string(), + tool_namespace, + tool: test_tool_definition(tool_name), + connector_id: Some(connector_id.to_string()), + connector_name: connector_name.map(ToOwned::to_owned), + connector_description: None, + plugin_display_names: plugin_names(plugin_display_names), + } +} + +fn with_accessible_connectors_cache_cleared(f: impl FnOnce() -> R) -> R { + let previous = { + let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + cache_guard.take() + }; + let result = f(); + let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *cache_guard = previous; + result +} + +#[test] +fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { + let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); + let accessible = google_calendar_accessible_connector(&[]); + + let merged = merge_connectors(vec![plugin], vec![accessible]); + + assert_eq!( + merged, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url("calendar", "calendar")), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }] + ); + assert_eq!(connector_mention_slug(&merged[0]), "google-calendar"); +} + +#[test] +fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { + let tools = HashMap::from([ + ( + "mcp__codex_apps__calendar_list_events".to_string(), + codex_app_tool( + "calendar_list_events", + "calendar", + None, + &["sample", "sample"], + ), + ), + ( + "mcp__codex_apps__calendar_create_event".to_string(), + codex_app_tool( + "calendar_create_event", + "calendar", + Some("Google Calendar"), + &["beta", "sample"], + ), + ), + ( + "mcp__sample__echo".to_string(), + ToolInfo { + server_name: "sample".to_string(), + tool_name: "echo".to_string(), + tool_namespace: "sample".to_string(), + tool: test_tool_definition("echo"), + connector_id: None, + connector_name: None, + connector_description: None, + plugin_display_names: plugin_names(&["ignored"]), + }, + ), + ]); + + let connectors = accessible_connectors_from_mcp_tools(&tools); + + assert_eq!( + connectors, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Google Calendar", "calendar")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(&["beta", "sample"]), + }] + ); +} + +#[tokio::test] +async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_installed_apps() { + let codex_home = tempdir().expect("tempdir should succeed"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config should load"); + let _ = config.features.set_enabled(Feature::Apps, true); + let cache_key = accessible_connectors_cache_key(&config, None); + let tools = HashMap::from([ + ( + "mcp__codex_apps__calendar_list_events".to_string(), + codex_app_tool( + "calendar_list_events", + "calendar", + Some("Google Calendar"), + &["calendar-plugin"], + ), + ), + ( + "mcp__codex_apps__openai_hidden".to_string(), + codex_app_tool( + "openai_hidden", + "connector_openai_hidden", + Some("Hidden"), + &[], + ), + ), + ]); + + let cached = with_accessible_connectors_cache_cleared(|| { + refresh_accessible_connectors_cache_from_mcp_tools(&config, None, &tools); + read_cached_accessible_connectors(&cache_key).expect("cache should be populated") + }); + + assert_eq!( + cached, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Google Calendar", "calendar")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(&["calendar-plugin"]), + }] + ); +} + +#[test] +fn merge_connectors_unions_and_dedupes_plugin_display_names() { + let mut plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); + plugin.plugin_display_names = plugin_names(&["sample", "alpha", "sample"]); + + let accessible = google_calendar_accessible_connector(&["beta", "alpha"]); + + let merged = merge_connectors(vec![plugin], vec![accessible]); + + assert_eq!( + merged, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url("calendar", "calendar")), + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(&["alpha", "beta", "sample"]), + }] + ); +} + +#[test] +fn accessible_connectors_from_mcp_tools_preserves_description() { + let mcp_tools = HashMap::from([( + "mcp__codex_apps__calendar_create_event".to_string(), + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "calendar_create_event".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: Tool { + name: "calendar_create_event".to_string().into(), + title: None, + description: Some("Create a calendar event".into()), + input_schema: Arc::new(JsonObject::default()), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: Some("Plan events".to_string()), + plugin_display_names: Vec::new(), + }, + )]); + + assert_eq!( + accessible_connectors_from_mcp_tools(&mcp_tools), + vec![AppInfo { + id: "calendar".to_string(), + name: "Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url("Calendar", "calendar")), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }] + ); +} + +#[test] +fn app_tool_policy_uses_global_defaults_for_destructive_hints() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: true, + destructive_enabled: false, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); +} + +#[test] +fn app_is_enabled_uses_default_for_unconfigured_apps() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + assert!(!app_is_enabled(&apps_config, Some("calendar"))); + assert!(!app_is_enabled(&apps_config, None)); +} + +#[test] +fn app_is_enabled_prefers_per_app_override_over_default() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: None, + }, + )]), + }; + + assert!(app_is_enabled(&apps_config, Some("calendar"))); + assert!(!app_is_enabled(&apps_config, Some("drive"))); +} + +#[test] +fn app_tool_policy_honors_default_app_enabled_false() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); +} + +#[test] +fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); +} + +#[test] +fn app_tool_policy_per_tool_enabled_true_overrides_app_level_disable_flags() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: Some(AppToolsConfig { + tools: HashMap::from([( + "events/create".to_string(), + AppToolConfig { + enabled: Some(true), + approval_mode: None, + }, + )]), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); +} + +#[test] +fn app_tool_policy_default_tools_enabled_true_overrides_app_level_tool_hints() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: None, + default_tools_enabled: Some(true), + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); +} + +#[test] +fn app_tool_policy_default_tools_enabled_false_overrides_app_level_tool_hints() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(true), + open_world_enabled: Some(true), + default_tools_approval_mode: Some(AppToolApproval::Approve), + default_tools_enabled: Some(false), + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Approve, + } + ); +} + +#[test] +fn app_tool_policy_uses_default_tools_approval_mode() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: Some(AppToolsConfig { + tools: HashMap::new(), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Prompt, + } + ); +} + +#[test] +fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: Some(AppToolApproval::Auto), + default_tools_enabled: Some(false), + tools: Some(AppToolsConfig { + tools: HashMap::from([( + "events/create".to_string(), + AppToolConfig { + enabled: Some(true), + approval_mode: Some(AppToolApproval::Approve), + }, + )]), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "calendar_events/create", + Some("events/create"), + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Approve, + } + ); +} + +#[test] +fn filter_disallowed_connectors_allows_non_disallowed_connectors() { + let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]); + assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]); +} + +#[test] +fn filter_disallowed_connectors_filters_openai_prefix() { + let filtered = filter_disallowed_connectors(vec![ + app("connector_openai_foo"), + app("connector_openai_bar"), + app("gamma"), + ]); + assert_eq!(filtered, vec![app("gamma")]); +} + +#[test] +fn filter_disallowed_connectors_filters_disallowed_connector_ids() { + let filtered = filter_disallowed_connectors(vec![ + app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("delta"), + ]); + assert_eq!(filtered, vec![app("delta")]); +} + +#[test] +fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() { + let filtered = filter_disallowed_connectors_for_originator( + vec![ + app("connector_openai_foo"), + app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"), + ], + "codex_atlas", + ); + assert_eq!( + filtered, + vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")] + ); +} + +#[test] +fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_apps() { + let filtered = filter_tool_suggest_discoverable_tools( + vec![ + named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ), + named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"), + named_app("connector_other", "Other"), + ], + &[AppInfo { + is_accessible: true, + ..named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ) + }], + ); + + assert_eq!( + filtered, + vec![named_app( + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail", + )] + ); +} + +#[test] +fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() { + let filtered = filter_tool_suggest_discoverable_tools( + vec![ + named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ), + named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"), + ], + &[ + AppInfo { + is_accessible: true, + ..named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ) + }, + AppInfo { + is_accessible: true, + is_enabled: false, + ..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail") + }, + ], + ); + + assert_eq!( + filtered, + vec![named_app( + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail" + )] + ); +} diff --git a/codex-rs/core/src/consequential_tool_message_templates.json b/codex-rs/core/src/consequential_tool_message_templates.json new file mode 100644 index 0000000000..83e11c79a2 --- /dev/null +++ b/codex-rs/core/src/consequential_tool_message_templates.json @@ -0,0 +1,962 @@ +{ + "schema_version": 4, + "templates": [ + { + "source_tool_index": 0, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_comment_to_issue", + "template_params": [ + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ], + "template": "Allow {connector_name} to add a comment to a pull request?" + }, + { + "source_tool_index": 1, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_reaction_to_issue_comment", + "template_params": [ + { + "name": "reaction", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to add a reaction to an issue comment?" + }, + { + "source_tool_index": 2, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_reaction_to_pr", + "template_params": [ + { + "name": "reaction", + "label": "Reaction" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to add a reaction to a pull request?" + }, + { + "source_tool_index": 3, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_reaction_to_pr_review_comment", + "template_params": [ + { + "name": "reaction", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to add a reaction to a pull request review comment?" + }, + { + "source_tool_index": 4, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_review_to_pr", + "template_params": [ + { + "name": "action", + "label": "Action" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "review", + "label": "Review" + } + ], + "template": "Allow {connector_name} to submit a pull request review?" + }, + { + "source_tool_index": 5, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_blob", + "template_params": [ + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "content", + "label": "Content" + } + ], + "template": "Allow {connector_name} to create a Git blob?" + }, + { + "source_tool_index": 6, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_branch", + "template_params": [ + { + "name": "branch_name", + "label": "Branch" + }, + { + "name": "repository_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to create a branch?" + }, + { + "source_tool_index": 7, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_commit", + "template_params": [ + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "message", + "label": "Message" + } + ], + "template": "Allow {connector_name} to create a commit?" + }, + { + "source_tool_index": 8, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_pull_request", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "head_branch", + "label": "Head branch" + }, + { + "name": "base_branch", + "label": "Base branch" + } + ], + "template": "Allow {connector_name} to create a pull request?" + }, + { + "source_tool_index": 9, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_tree", + "template_params": [ + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "tree_elements", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to create a Git tree?" + }, + { + "source_tool_index": 10, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "enable_auto_merge", + "template_params": [ + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repository_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to enable pull request auto-merge?" + }, + { + "source_tool_index": 11, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "label_pr", + "template_params": [ + { + "name": "label", + "label": "Label" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repository_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to add a label to a pull request?" + }, + { + "source_tool_index": 12, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "remove_reaction_from_issue_comment", + "template_params": [ + { + "name": "reaction_id", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to remove a reaction from an issue comment?" + }, + { + "source_tool_index": 13, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "remove_reaction_from_pr", + "template_params": [ + { + "name": "reaction_id", + "label": "Reaction" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to remove a reaction from a pull request?" + }, + { + "source_tool_index": 14, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "remove_reaction_from_pr_review_comment", + "template_params": [ + { + "name": "reaction_id", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ], + "template": "Allow {connector_name} to remove a reaction from a pull request review comment?" + }, + { + "source_tool_index": 15, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "reply_to_review_comment", + "template_params": [ + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ], + "template": "Allow {connector_name} to reply to a pull request review comment?" + }, + { + "source_tool_index": 16, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "update_issue_comment", + "template_params": [ + { + "name": "comment_id", + "label": "Comment ID" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ], + "template": "Allow {connector_name} to update an issue comment?" + }, + { + "source_tool_index": 17, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "update_ref", + "template_params": [ + { + "name": "branch_name", + "label": "Branch" + }, + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "sha", + "label": "Commit" + } + ], + "template": "Allow {connector_name} to update a branch reference?" + }, + { + "source_tool_index": 18, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "update_review_comment", + "template_params": [ + { + "name": "comment_id", + "label": "Comment ID" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ], + "template": "Allow {connector_name} to update a pull request review comment?" + }, + { + "source_tool_index": 19, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "create_event", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "start_time", + "label": "Start" + }, + { + "name": "attendees", + "label": "Attendees" + } + ], + "template": "Allow {connector_name} to create an event?" + }, + { + "source_tool_index": 20, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "delete_event", + "template_params": [ + { + "name": "event_id", + "label": "Event" + } + ], + "template": "Allow {connector_name} to delete an event?" + }, + { + "source_tool_index": 21, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "respond_event", + "template_params": [ + { + "name": "response_status", + "label": "Response Status" + }, + { + "name": "event_id", + "label": "Event" + } + ], + "template": "Allow {connector_name} to respond to an event?" + }, + { + "source_tool_index": 22, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "update_event", + "template_params": [ + { + "name": "event_id", + "label": "Event" + } + ], + "template": "Allow {connector_name} to update an event?" + }, + { + "source_tool_index": 23, + "connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c", + "server_name": "codex_apps", + "tool_title": "batch_update", + "template_params": [ + { + "name": "spreadsheet_url", + "label": "Spreadsheet" + }, + { + "name": "requests", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to apply spreadsheet updates?" + }, + { + "source_tool_index": 24, + "connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c", + "server_name": "codex_apps", + "tool_title": "create_spreadsheet", + "template_params": [ + { + "name": "title", + "label": "Title" + } + ], + "template": "Allow {connector_name} to create a spreadsheet?" + }, + { + "source_tool_index": 25, + "connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c", + "server_name": "codex_apps", + "tool_title": "duplicate_sheet_in_new_file", + "template_params": [ + { + "name": "source_sheet_name", + "label": "Source Sheet Name" + }, + { + "name": "spreadsheet_url", + "label": "Spreadsheet" + }, + { + "name": "new_file_name", + "label": "New File Name" + } + ], + "template": "Allow {connector_name} to copy a sheet into a new spreadsheet?" + }, + { + "source_tool_index": 26, + "connector_id": "connector_6f1ec045b8fa4ced8738e32c7f74514b", + "server_name": "codex_apps", + "tool_title": "batch_update", + "template_params": [ + { + "name": "presentation_url", + "label": "Presentation" + }, + { + "name": "requests", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to apply presentation updates?" + }, + { + "source_tool_index": 27, + "connector_id": "connector_6f1ec045b8fa4ced8738e32c7f74514b", + "server_name": "codex_apps", + "tool_title": "create_presentation", + "template_params": [ + { + "name": "title", + "label": "Title" + } + ], + "template": "Allow {connector_name} to create a presentation?" + }, + { + "source_tool_index": 28, + "connector_id": "connector_4964e3b22e3e427e9b4ae1acf2c1fa34", + "server_name": "codex_apps", + "tool_title": "batch_update", + "template_params": [ + { + "name": "document_url", + "label": "Document" + }, + { + "name": "requests", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to apply document updates?" + }, + { + "source_tool_index": 29, + "connector_id": "connector_4964e3b22e3e427e9b4ae1acf2c1fa34", + "server_name": "codex_apps", + "tool_title": "create_document", + "template_params": [ + { + "name": "title", + "label": "Title" + } + ], + "template": "Allow {connector_name} to create a document?" + }, + { + "source_tool_index": 30, + "connector_id": "connector_5f3c8c41a1e54ad7a76272c89e2554fa", + "server_name": "codex_apps", + "tool_title": "copy_document", + "template_params": [ + { + "name": "url", + "label": "URL" + } + ], + "template": "Allow {connector_name} to copy a file?" + }, + { + "source_tool_index": 31, + "connector_id": "connector_5f3c8c41a1e54ad7a76272c89e2554fa", + "server_name": "codex_apps", + "tool_title": "share_document", + "template_params": [ + { + "name": "url", + "label": "URL" + }, + { + "name": "permission", + "label": "Permission" + } + ], + "template": "Allow {connector_name} to change file sharing?" + }, + { + "source_tool_index": 32, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_send_message", + "template_params": [ + { + "name": "channel_id", + "label": "Conversation" + }, + { + "name": "message", + "label": "Message" + } + ], + "template": "Allow {connector_name} to send a message?" + }, + { + "source_tool_index": 33, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_schedule_message", + "template_params": [ + { + "name": "channel_id", + "label": "Conversation" + }, + { + "name": "post_at", + "label": "Send at" + }, + { + "name": "message", + "label": "Message" + } + ], + "template": "Allow {connector_name} to schedule a message?" + }, + { + "source_tool_index": 34, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_create_canvas", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "content", + "label": "Content" + } + ], + "template": "Allow {connector_name} to create a canvas?" + }, + { + "source_tool_index": 35, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_send_message_draft", + "template_params": [ + { + "name": "channel_id", + "label": "Conversation" + }, + { + "name": "message", + "label": "Message" + } + ], + "template": "Allow {connector_name} to create a message draft?" + }, + { + "source_tool_index": 36, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "add_comment_to_issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "body", + "label": "Body" + } + ], + "template": "Allow {connector_name} to add a comment to an issue?" + }, + { + "source_tool_index": 37, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "add_label_to_issue", + "template_params": [ + { + "name": "label_id", + "label": "Label" + }, + { + "name": "issue_id", + "label": "Issue" + } + ], + "template": "Allow {connector_name} to add a label to an issue?" + }, + { + "source_tool_index": 38, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "add_url_attachment_to_issue", + "template_params": [ + { + "name": "url", + "label": "URL" + }, + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "title", + "label": "Title" + } + ], + "template": "Allow {connector_name} to attach a link to an issue?" + }, + { + "source_tool_index": 39, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "assign_issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "user_id", + "label": "User" + } + ], + "template": "Allow {connector_name} to assign an issue?" + }, + { + "source_tool_index": 40, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "create_issue", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "team_id", + "label": "Team" + } + ], + "template": "Allow {connector_name} to create an issue?" + }, + { + "source_tool_index": 41, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "create_label", + "template_params": [ + { + "name": "label_name", + "label": "Label Name" + } + ], + "template": "Allow {connector_name} to create a label?" + }, + { + "source_tool_index": 42, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "create_project", + "template_params": [ + { + "name": "name", + "label": "Name" + }, + { + "name": "team_id", + "label": "Team" + } + ], + "template": "Allow {connector_name} to create a project?" + }, + { + "source_tool_index": 43, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "remove_label_from_issue", + "template_params": [ + { + "name": "label_id", + "label": "Label" + }, + { + "name": "issue_id", + "label": "Issue" + } + ], + "template": "Allow {connector_name} to remove a label from an issue?" + }, + { + "source_tool_index": 44, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "resolve_comment", + "template_params": [ + { + "name": "comment_id", + "label": "Comment" + } + ], + "template": "Allow {connector_name} to resolve a comment?" + }, + { + "source_tool_index": 45, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "set_issue_state", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "state_id", + "label": "State" + } + ], + "template": "Allow {connector_name} to change issue state?" + }, + { + "source_tool_index": 46, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "unassign_issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + } + ], + "template": "Allow {connector_name} to unassign an issue?" + }, + { + "source_tool_index": 47, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "update_issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "issue_update", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to update an issue?" + }, + { + "source_tool_index": 48, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "update_project", + "template_params": [ + { + "name": "project_id", + "label": "Project" + }, + { + "name": "update_fields", + "label": "Changes" + } + ], + "template": "Allow {connector_name} to update a project?" + }, + { + "source_tool_index": 49, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "apply_labels_to_emails", + "template_params": [], + "template": "Allow {connector_name} to apply label changes to messages?" + }, + { + "source_tool_index": 50, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "batch_modify_email", + "template_params": [], + "template": "Allow {connector_name} to update message labels?" + }, + { + "source_tool_index": 51, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "bulk_label_matching_emails", + "template_params": [ + { + "name": "label_name", + "label": "Label Name" + }, + { + "name": "query", + "label": "Query" + } + ], + "template": "Allow {connector_name} to label matching messages?" + }, + { + "source_tool_index": 52, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "create_draft", + "template_params": [ + { + "name": "to", + "label": "To" + }, + { + "name": "subject", + "label": "Subject" + }, + { + "name": "body", + "label": "Body" + } + ], + "template": "Allow {connector_name} to create an email draft?" + }, + { + "source_tool_index": 53, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "create_label", + "template_params": [ + { + "name": "name", + "label": "Name" + } + ], + "template": "Allow {connector_name} to create a label?" + }, + { + "source_tool_index": 54, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "send_email", + "template_params": [ + { + "name": "to", + "label": "To" + }, + { + "name": "subject", + "label": "Subject" + }, + { + "name": "body", + "label": "Body" + } + ], + "template": "Allow {connector_name} to send an email?" + } + ] +} diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 1bafca4082..05ac09ba18 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -376,6 +376,8 @@ impl ContextManager { | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } @@ -413,6 +415,8 @@ fn is_api_message(message: &ResponseItem) -> bool { ResponseItem::Message { role, .. } => role.as_str() != "system", ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::LocalShellCall { .. } @@ -605,12 +609,14 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { ResponseItem::Message { role, .. } => role == "assistant", ResponseItem::Reasoning { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::Compaction { .. } => true, ResponseItem::FunctionCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::GhostSnapshot { .. } | ResponseItem::Other => false, @@ -620,7 +626,9 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { pub(crate) fn is_codex_generated_item(item: &ResponseItem) -> bool { matches!( item, - ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } + ResponseItem::FunctionCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::CustomToolCallOutput { .. } ) || matches!(item, ResponseItem::Message { role, .. } if role == "developer") } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 6bfdf62aa1..ea37b39477 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -278,6 +278,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { ResponseItem::FunctionCall { id: None, name: "view_image".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), }, @@ -340,6 +341,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { ResponseItem::FunctionCall { id: None, name: "view_image".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), }, @@ -562,6 +564,7 @@ fn remove_first_item_removes_matching_output_for_function_call() { ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), }, @@ -585,6 +588,7 @@ fn remove_first_item_removes_matching_call_for_output() { ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-2".to_string(), }, @@ -601,6 +605,7 @@ fn remove_last_item_removes_matching_call_for_output() { ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-delete-last".to_string(), }, @@ -1075,6 +1080,7 @@ fn normalize_adds_missing_output_for_function_call() { let items = vec![ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), }]; @@ -1088,6 +1094,7 @@ fn normalize_adds_missing_output_for_function_call() { ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), }, @@ -1209,6 +1216,7 @@ fn normalize_mixed_inserts_and_removals() { ResponseItem::FunctionCall { id: None, name: "f1".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), }, @@ -1249,6 +1257,7 @@ fn normalize_mixed_inserts_and_removals() { ResponseItem::FunctionCall { id: None, name: "f1".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), }, @@ -1292,6 +1301,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { let items = vec![ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), }]; @@ -1303,6 +1313,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), }, @@ -1314,6 +1325,39 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { ); } +#[test] +fn normalize_adds_missing_output_for_tool_search_call() { + let items = vec![ResponseItem::ToolSearchCall { + id: None, + call_id: Some("search-call-x".to_string()), + status: Some("completed".to_string()), + execution: "client".to_string(), + arguments: "{}".into(), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(&default_input_modalities()); + + assert_eq!( + h.raw_items(), + vec![ + ResponseItem::ToolSearchCall { + id: None, + call_id: Some("search-call-x".to_string()), + status: Some("completed".to_string()), + execution: "client".to_string(), + arguments: "{}".into(), + }, + ResponseItem::ToolSearchOutput { + call_id: Some("search-call-x".to_string()), + status: "completed".to_string(), + execution: "client".to_string(), + tools: Vec::new(), + }, + ] + ); +} + #[cfg(debug_assertions)] #[test] #[should_panic] @@ -1373,6 +1417,59 @@ fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { h.normalize_history(&default_input_modalities()); } +#[cfg(not(debug_assertions))] +#[test] +fn normalize_removes_orphan_client_tool_search_output() { + let items = vec![ResponseItem::ToolSearchOutput { + call_id: Some("orphan-search".to_string()), + status: "completed".to_string(), + execution: "client".to_string(), + tools: Vec::new(), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(&default_input_modalities()); + + assert_eq!(h.raw_items(), vec![]); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic] +fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() { + let items = vec![ResponseItem::ToolSearchOutput { + call_id: Some("orphan-search".to_string()), + status: "completed".to_string(), + execution: "client".to_string(), + tools: Vec::new(), + }]; + let mut h = create_history_with_items(items); + h.normalize_history(&default_input_modalities()); +} + +#[test] +fn normalize_keeps_server_tool_search_output_without_matching_call() { + let items = vec![ResponseItem::ToolSearchOutput { + call_id: Some("server-search".to_string()), + status: "completed".to_string(), + execution: "server".to_string(), + tools: Vec::new(), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(&default_input_modalities()); + + assert_eq!( + h.raw_items(), + vec![ResponseItem::ToolSearchOutput { + call_id: Some("server-search".to_string()), + status: "completed".to_string(), + execution: "server".to_string(), + tools: Vec::new(), + }] + ); +} + #[cfg(debug_assertions)] #[test] #[should_panic] @@ -1381,6 +1478,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { ResponseItem::FunctionCall { id: None, name: "f1".to_string(), + namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), }, diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index d185704bd7..0d17022fc1 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -38,6 +38,31 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { )); } } + ResponseItem::ToolSearchCall { + call_id: Some(call_id), + .. + } => { + let has_output = items.iter().any(|i| match i { + ResponseItem::ToolSearchOutput { + call_id: Some(existing), + .. + } => existing == call_id, + _ => false, + }); + + if !has_output { + info!("Tool search output is missing for call id: {call_id}"); + missing_outputs_to_insert.push(( + idx, + ResponseItem::ToolSearchOutput { + call_id: Some(call_id.clone()), + status: "completed".to_string(), + execution: "client".to_string(), + tools: Vec::new(), + }, + )); + } + } ResponseItem::CustomToolCall { call_id, .. } => { let has_output = items.iter().any(|i| match i { ResponseItem::CustomToolCallOutput { @@ -102,6 +127,17 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec) { }) .collect(); + let tool_search_call_ids: HashSet = items + .iter() + .filter_map(|i| match i { + ResponseItem::ToolSearchCall { + call_id: Some(call_id), + .. + } => Some(call_id.clone()), + _ => None, + }) + .collect(); + let local_shell_call_ids: HashSet = items .iter() .filter_map(|i| match i { @@ -141,6 +177,18 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec) { } has_match } + ResponseItem::ToolSearchOutput { execution, .. } if execution == "server" => true, + ResponseItem::ToolSearchOutput { + call_id: Some(call_id), + .. + } => { + let has_match = tool_search_call_ids.contains(call_id); + if !has_match { + error_or_panic(format!("Orphan tool search output for call id: {call_id}")); + } + has_match + } + ResponseItem::ToolSearchOutput { call_id: None, .. } => true, _ => true, }); } @@ -168,6 +216,37 @@ pub(crate) fn remove_corresponding_for(items: &mut Vec, item: &Res items.remove(pos); } } + ResponseItem::ToolSearchCall { + call_id: Some(call_id), + .. + } => { + remove_first_matching(items, |i| { + matches!( + i, + ResponseItem::ToolSearchOutput { + call_id: Some(existing), + .. + } if existing == call_id + ) + }); + } + ResponseItem::ToolSearchOutput { + call_id: Some(call_id), + .. + } => { + remove_first_matching( + items, + |i| { + matches!( + i, + ResponseItem::ToolSearchCall { + call_id: Some(existing), + .. + } if existing == call_id + ) + }, + ); + } ResponseItem::CustomToolCall { call_id, .. } => { remove_first_matching(items, |i| { matches!( diff --git a/codex-rs/core/src/contextual_user_message.rs b/codex-rs/core/src/contextual_user_message.rs index 05c68f7858..b069aa409e 100644 --- a/codex-rs/core/src/contextual_user_message.rs +++ b/codex-rs/core/src/contextual_user_message.rs @@ -105,36 +105,5 @@ pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn detects_environment_context_fragment() { - assert!(is_contextual_user_fragment(&ContentItem::InputText { - text: "\n/tmp\n".to_string(), - })); - } - - #[test] - fn detects_agents_instructions_fragment() { - assert!(is_contextual_user_fragment(&ContentItem::InputText { - text: "# AGENTS.md instructions for /tmp\n\n\nbody\n" - .to_string(), - })); - } - - #[test] - fn detects_subagent_notification_fragment_case_insensitively() { - assert!( - SUBAGENT_NOTIFICATION_FRAGMENT - .matches_text("{}") - ); - } - - #[test] - fn ignores_regular_user_text() { - assert!(!is_contextual_user_fragment(&ContentItem::InputText { - text: "hello".to_string(), - })); - } -} +#[path = "contextual_user_message_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/contextual_user_message_tests.rs b/codex-rs/core/src/contextual_user_message_tests.rs new file mode 100644 index 0000000000..df3a9daeca --- /dev/null +++ b/codex-rs/core/src/contextual_user_message_tests.rs @@ -0,0 +1,31 @@ +use super::*; + +#[test] +fn detects_environment_context_fragment() { + assert!(is_contextual_user_fragment(&ContentItem::InputText { + text: "\n/tmp\n".to_string(), + })); +} + +#[test] +fn detects_agents_instructions_fragment() { + assert!(is_contextual_user_fragment(&ContentItem::InputText { + text: "# AGENTS.md instructions for /tmp\n\n\nbody\n" + .to_string(), + })); +} + +#[test] +fn detects_subagent_notification_fragment_case_insensitively() { + assert!( + SUBAGENT_NOTIFICATION_FRAGMENT + .matches_text("{}") + ); +} + +#[test] +fn ignores_regular_user_text() { + assert!(!is_contextual_user_fragment(&ContentItem::InputText { + text: "hello".to_string(), + })); +} diff --git a/codex-rs/core/src/custom_prompts.rs b/codex-rs/core/src/custom_prompts.rs index 66b2bab32c..54ccaa62fe 100644 --- a/codex-rs/core/src/custom_prompts.rs +++ b/codex-rs/core/src/custom_prompts.rs @@ -145,100 +145,5 @@ fn parse_frontmatter(content: &str) -> (Option, Option, String) } #[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::tempdir; - - #[tokio::test] - async fn empty_when_dir_missing() { - let tmp = tempdir().expect("create TempDir"); - let missing = tmp.path().join("nope"); - let found = discover_prompts_in(&missing).await; - assert!(found.is_empty()); - } - - #[tokio::test] - async fn discovers_and_sorts_files() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - fs::write(dir.join("b.md"), b"b").unwrap(); - fs::write(dir.join("a.md"), b"a").unwrap(); - fs::create_dir(dir.join("subdir")).unwrap(); - let found = discover_prompts_in(dir).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - assert_eq!(names, vec!["a", "b"]); - } - - #[tokio::test] - async fn excludes_builtins() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - fs::write(dir.join("init.md"), b"ignored").unwrap(); - fs::write(dir.join("foo.md"), b"ok").unwrap(); - let mut exclude = HashSet::new(); - exclude.insert("init".to_string()); - let found = discover_prompts_in_excluding(dir, &exclude).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - assert_eq!(names, vec!["foo"]); - } - - #[tokio::test] - async fn skips_non_utf8_files() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - // Valid UTF-8 file - fs::write(dir.join("good.md"), b"hello").unwrap(); - // Invalid UTF-8 content in .md file (e.g., lone 0xFF byte) - fs::write(dir.join("bad.md"), vec![0xFF, 0xFE, b'\n']).unwrap(); - let found = discover_prompts_in(dir).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - assert_eq!(names, vec!["good"]); - } - - #[tokio::test] - #[cfg(unix)] - async fn discovers_symlinked_md_files() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - - // Create a real file - fs::write(dir.join("real.md"), b"real content").unwrap(); - - // Create a symlink to the real file - std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md")).unwrap(); - - let found = discover_prompts_in(dir).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - - // Both real and link should be discovered, sorted alphabetically - assert_eq!(names, vec!["link", "real"]); - } - - #[tokio::test] - async fn parses_frontmatter_and_strips_from_body() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - let file = dir.join("withmeta.md"); - let text = "---\nname: ignored\ndescription: \"Quick review command\"\nargument-hint: \"[file] [priority]\"\n---\nActual body with $1 and $ARGUMENTS"; - fs::write(&file, text).unwrap(); - - let found = discover_prompts_in(dir).await; - assert_eq!(found.len(), 1); - let p = &found[0]; - assert_eq!(p.name, "withmeta"); - assert_eq!(p.description.as_deref(), Some("Quick review command")); - assert_eq!(p.argument_hint.as_deref(), Some("[file] [priority]")); - // Body should not include the frontmatter delimiters. - assert_eq!(p.content, "Actual body with $1 and $ARGUMENTS"); - } - - #[test] - fn parse_frontmatter_preserves_body_newlines() { - let content = "---\r\ndescription: \"Line endings\"\r\nargument_hint: \"[arg]\"\r\n---\r\nFirst line\r\nSecond line\r\n"; - let (desc, hint, body) = parse_frontmatter(content); - assert_eq!(desc.as_deref(), Some("Line endings")); - assert_eq!(hint.as_deref(), Some("[arg]")); - assert_eq!(body, "First line\r\nSecond line\r\n"); - } -} +#[path = "custom_prompts_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/custom_prompts_tests.rs b/codex-rs/core/src/custom_prompts_tests.rs new file mode 100644 index 0000000000..b1208a04e0 --- /dev/null +++ b/codex-rs/core/src/custom_prompts_tests.rs @@ -0,0 +1,95 @@ +use super::*; +use std::fs; +use tempfile::tempdir; + +#[tokio::test] +async fn empty_when_dir_missing() { + let tmp = tempdir().expect("create TempDir"); + let missing = tmp.path().join("nope"); + let found = discover_prompts_in(&missing).await; + assert!(found.is_empty()); +} + +#[tokio::test] +async fn discovers_and_sorts_files() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + fs::write(dir.join("b.md"), b"b").unwrap(); + fs::write(dir.join("a.md"), b"a").unwrap(); + fs::create_dir(dir.join("subdir")).unwrap(); + let found = discover_prompts_in(dir).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["a", "b"]); +} + +#[tokio::test] +async fn excludes_builtins() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + fs::write(dir.join("init.md"), b"ignored").unwrap(); + fs::write(dir.join("foo.md"), b"ok").unwrap(); + let mut exclude = HashSet::new(); + exclude.insert("init".to_string()); + let found = discover_prompts_in_excluding(dir, &exclude).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["foo"]); +} + +#[tokio::test] +async fn skips_non_utf8_files() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + // Valid UTF-8 file + fs::write(dir.join("good.md"), b"hello").unwrap(); + // Invalid UTF-8 content in .md file (e.g., lone 0xFF byte) + fs::write(dir.join("bad.md"), vec![0xFF, 0xFE, b'\n']).unwrap(); + let found = discover_prompts_in(dir).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["good"]); +} + +#[tokio::test] +#[cfg(unix)] +async fn discovers_symlinked_md_files() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + + // Create a real file + fs::write(dir.join("real.md"), b"real content").unwrap(); + + // Create a symlink to the real file + std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md")).unwrap(); + + let found = discover_prompts_in(dir).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + + // Both real and link should be discovered, sorted alphabetically + assert_eq!(names, vec!["link", "real"]); +} + +#[tokio::test] +async fn parses_frontmatter_and_strips_from_body() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + let file = dir.join("withmeta.md"); + let text = "---\nname: ignored\ndescription: \"Quick review command\"\nargument-hint: \"[file] [priority]\"\n---\nActual body with $1 and $ARGUMENTS"; + fs::write(&file, text).unwrap(); + + let found = discover_prompts_in(dir).await; + assert_eq!(found.len(), 1); + let p = &found[0]; + assert_eq!(p.name, "withmeta"); + assert_eq!(p.description.as_deref(), Some("Quick review command")); + assert_eq!(p.argument_hint.as_deref(), Some("[file] [priority]")); + // Body should not include the frontmatter delimiters. + assert_eq!(p.content, "Actual body with $1 and $ARGUMENTS"); +} + +#[test] +fn parse_frontmatter_preserves_body_newlines() { + let content = "---\r\ndescription: \"Line endings\"\r\nargument_hint: \"[arg]\"\r\n---\r\nFirst line\r\nSecond line\r\n"; + let (desc, hint, body) = parse_frontmatter(content); + assert_eq!(desc.as_deref(), Some("Line endings")); + assert_eq!(hint.as_deref(), Some("[arg]")); + assert_eq!(body, "First line\r\nSecond line\r\n"); +} diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index aa490e8264..6d0b5496ce 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -216,128 +216,5 @@ fn is_sandboxed() -> bool { } #[cfg(test)] -mod tests { - use super::*; - use core_test_support::skip_if_no_network; - use pretty_assertions::assert_eq; - - #[test] - fn test_get_codex_user_agent() { - let user_agent = get_codex_user_agent(); - let originator = originator().value; - let prefix = format!("{originator}/"); - assert!(user_agent.starts_with(&prefix)); - } - - #[test] - fn is_first_party_originator_matches_known_values() { - assert_eq!(is_first_party_originator(DEFAULT_ORIGINATOR), true); - assert_eq!(is_first_party_originator("codex_vscode"), true); - assert_eq!(is_first_party_originator("Codex Something Else"), true); - assert_eq!(is_first_party_originator("codex_cli"), false); - assert_eq!(is_first_party_originator("Other"), false); - } - - #[test] - fn is_first_party_chat_originator_matches_known_values() { - assert_eq!(is_first_party_chat_originator("codex_atlas"), true); - assert_eq!( - is_first_party_chat_originator("codex_chatgpt_desktop"), - true - ); - assert_eq!(is_first_party_chat_originator(DEFAULT_ORIGINATOR), false); - assert_eq!(is_first_party_chat_originator("codex_vscode"), false); - } - - #[tokio::test] - async fn test_create_client_sets_default_headers() { - skip_if_no_network!(); - - set_default_client_residency_requirement(Some(ResidencyRequirement::Us)); - - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - - let client = create_client(); - - // Spin up a local mock server and capture a request. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/")) - .respond_with(ResponseTemplate::new(200)) - .mount(&server) - .await; - - let resp = client - .get(server.uri()) - .send() - .await - .expect("failed to send request"); - assert!(resp.status().is_success()); - - let requests = server - .received_requests() - .await - .expect("failed to fetch received requests"); - assert!(!requests.is_empty()); - let headers = &requests[0].headers; - - // originator header is set to the provided value - let originator_header = headers - .get("originator") - .expect("originator header missing"); - assert_eq!(originator_header.to_str().unwrap(), originator().value); - - // User-Agent matches the computed Codex UA for that originator - let expected_ua = get_codex_user_agent(); - let ua_header = headers - .get("user-agent") - .expect("user-agent header missing"); - assert_eq!(ua_header.to_str().unwrap(), expected_ua); - - let residency_header = headers - .get(RESIDENCY_HEADER_NAME) - .expect("residency header missing"); - assert_eq!(residency_header.to_str().unwrap(), "us"); - - set_default_client_residency_requirement(None); - } - - #[test] - fn test_invalid_suffix_is_sanitized() { - let prefix = "codex_cli_rs/0.0.0"; - let suffix = "bad\rsuffix"; - - assert_eq!( - sanitize_user_agent(format!("{prefix} ({suffix})"), prefix), - "codex_cli_rs/0.0.0 (bad_suffix)" - ); - } - - #[test] - fn test_invalid_suffix_is_sanitized2() { - let prefix = "codex_cli_rs/0.0.0"; - let suffix = "bad\0suffix"; - - assert_eq!( - sanitize_user_agent(format!("{prefix} ({suffix})"), prefix), - "codex_cli_rs/0.0.0 (bad_suffix)" - ); - } - - #[test] - #[cfg(target_os = "macos")] - fn test_macos() { - use regex_lite::Regex; - let user_agent = get_codex_user_agent(); - let originator = regex_lite::escape(originator().value.as_str()); - let re = Regex::new(&format!( - r"^{originator}/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$" - )) - .unwrap(); - assert!(re.is_match(&user_agent)); - } -} +#[path = "default_client_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/default_client_tests.rs b/codex-rs/core/src/default_client_tests.rs new file mode 100644 index 0000000000..44d5e2c3c9 --- /dev/null +++ b/codex-rs/core/src/default_client_tests.rs @@ -0,0 +1,123 @@ +use super::*; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; + +#[test] +fn test_get_codex_user_agent() { + let user_agent = get_codex_user_agent(); + let originator = originator().value; + let prefix = format!("{originator}/"); + assert!(user_agent.starts_with(&prefix)); +} + +#[test] +fn is_first_party_originator_matches_known_values() { + assert_eq!(is_first_party_originator(DEFAULT_ORIGINATOR), true); + assert_eq!(is_first_party_originator("codex_vscode"), true); + assert_eq!(is_first_party_originator("Codex Something Else"), true); + assert_eq!(is_first_party_originator("codex_cli"), false); + assert_eq!(is_first_party_originator("Other"), false); +} + +#[test] +fn is_first_party_chat_originator_matches_known_values() { + assert_eq!(is_first_party_chat_originator("codex_atlas"), true); + assert_eq!( + is_first_party_chat_originator("codex_chatgpt_desktop"), + true + ); + assert_eq!(is_first_party_chat_originator(DEFAULT_ORIGINATOR), false); + assert_eq!(is_first_party_chat_originator("codex_vscode"), false); +} + +#[tokio::test] +async fn test_create_client_sets_default_headers() { + skip_if_no_network!(); + + set_default_client_residency_requirement(Some(ResidencyRequirement::Us)); + + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let client = create_client(); + + // Spin up a local mock server and capture a request. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let resp = client + .get(server.uri()) + .send() + .await + .expect("failed to send request"); + assert!(resp.status().is_success()); + + let requests = server + .received_requests() + .await + .expect("failed to fetch received requests"); + assert!(!requests.is_empty()); + let headers = &requests[0].headers; + + // originator header is set to the provided value + let originator_header = headers + .get("originator") + .expect("originator header missing"); + assert_eq!(originator_header.to_str().unwrap(), originator().value); + + // User-Agent matches the computed Codex UA for that originator + let expected_ua = get_codex_user_agent(); + let ua_header = headers + .get("user-agent") + .expect("user-agent header missing"); + assert_eq!(ua_header.to_str().unwrap(), expected_ua); + + let residency_header = headers + .get(RESIDENCY_HEADER_NAME) + .expect("residency header missing"); + assert_eq!(residency_header.to_str().unwrap(), "us"); + + set_default_client_residency_requirement(None); +} + +#[test] +fn test_invalid_suffix_is_sanitized() { + let prefix = "codex_cli_rs/0.0.0"; + let suffix = "bad\rsuffix"; + + assert_eq!( + sanitize_user_agent(format!("{prefix} ({suffix})"), prefix), + "codex_cli_rs/0.0.0 (bad_suffix)" + ); +} + +#[test] +fn test_invalid_suffix_is_sanitized2() { + let prefix = "codex_cli_rs/0.0.0"; + let suffix = "bad\0suffix"; + + assert_eq!( + sanitize_user_agent(format!("{prefix} ({suffix})"), prefix), + "codex_cli_rs/0.0.0 (bad_suffix)" + ); +} + +#[test] +#[cfg(target_os = "macos")] +fn test_macos() { + use regex_lite::Regex; + let user_agent = get_codex_user_agent(); + let originator = regex_lite::escape(originator().value.as_str()); + let re = Regex::new(&format!( + r"^{originator}/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$" + )) + .unwrap(); + assert!(re.is_match(&user_agent)); +} diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 3e9ed871e3..0c42cd0900 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -199,279 +199,5 @@ impl From for ResponseItem { } #[cfg(test)] -mod tests { - use crate::shell::ShellType; - - use super::*; - use core_test_support::test_path_buf; - use pretty_assertions::assert_eq; - - fn fake_shell() -> Shell { - Shell { - shell_type: ShellType::Bash, - shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - } - } - - #[test] - fn serialize_workspace_write_environment_context() { - let cwd = test_path_buf("/repo"); - let context = EnvironmentContext::new( - Some(cwd.clone()), - fake_shell(), - Some("2026-02-26".to_string()), - Some("America/Los_Angeles".to_string()), - None, - None, - ); - - let expected = format!( - r#" - {cwd} - bash - 2026-02-26 - America/Los_Angeles -"#, - cwd = cwd.display(), - ); - - assert_eq!(context.serialize_to_xml(), expected); - } - - #[test] - fn serialize_environment_context_with_network() { - let network = NetworkContext { - allowed_domains: vec!["api.example.com".to_string(), "*.openai.com".to_string()], - denied_domains: vec!["blocked.example.com".to_string()], - }; - let context = EnvironmentContext::new( - Some(test_path_buf("/repo")), - fake_shell(), - Some("2026-02-26".to_string()), - Some("America/Los_Angeles".to_string()), - Some(network), - None, - ); - - let expected = format!( - r#" - {} - bash - 2026-02-26 - America/Los_Angeles - - api.example.com - *.openai.com - blocked.example.com - -"#, - test_path_buf("/repo").display() - ); - - assert_eq!(context.serialize_to_xml(), expected); - } - - #[test] - fn serialize_read_only_environment_context() { - let context = EnvironmentContext::new( - None, - fake_shell(), - Some("2026-02-26".to_string()), - Some("America/Los_Angeles".to_string()), - None, - None, - ); - - let expected = r#" - bash - 2026-02-26 - America/Los_Angeles -"#; - - assert_eq!(context.serialize_to_xml(), expected); - } - - #[test] - fn serialize_external_sandbox_environment_context() { - let context = EnvironmentContext::new( - None, - fake_shell(), - Some("2026-02-26".to_string()), - Some("America/Los_Angeles".to_string()), - None, - None, - ); - - let expected = r#" - bash - 2026-02-26 - America/Los_Angeles -"#; - - assert_eq!(context.serialize_to_xml(), expected); - } - - #[test] - fn serialize_external_sandbox_with_restricted_network_environment_context() { - let context = EnvironmentContext::new( - None, - fake_shell(), - Some("2026-02-26".to_string()), - Some("America/Los_Angeles".to_string()), - None, - None, - ); - - let expected = r#" - bash - 2026-02-26 - America/Los_Angeles -"#; - - assert_eq!(context.serialize_to_xml(), expected); - } - - #[test] - fn serialize_full_access_environment_context() { - let context = EnvironmentContext::new( - None, - fake_shell(), - Some("2026-02-26".to_string()), - Some("America/Los_Angeles".to_string()), - None, - None, - ); - - let expected = r#" - bash - 2026-02-26 - America/Los_Angeles -"#; - - assert_eq!(context.serialize_to_xml(), expected); - } - - #[test] - fn equals_except_shell_compares_cwd() { - let context1 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - fake_shell(), - None, - None, - None, - None, - ); - let context2 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - fake_shell(), - None, - None, - None, - None, - ); - assert!(context1.equals_except_shell(&context2)); - } - - #[test] - fn equals_except_shell_ignores_sandbox_policy() { - let context1 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - fake_shell(), - None, - None, - None, - None, - ); - let context2 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - fake_shell(), - None, - None, - None, - None, - ); - - assert!(context1.equals_except_shell(&context2)); - } - - #[test] - fn equals_except_shell_compares_cwd_differences() { - let context1 = EnvironmentContext::new( - Some(PathBuf::from("/repo1")), - fake_shell(), - None, - None, - None, - None, - ); - let context2 = EnvironmentContext::new( - Some(PathBuf::from("/repo2")), - fake_shell(), - None, - None, - None, - None, - ); - - assert!(!context1.equals_except_shell(&context2)); - } - - #[test] - fn equals_except_shell_ignores_shell() { - let context1 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - Shell { - shell_type: ShellType::Bash, - shell_path: "/bin/bash".into(), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }, - None, - None, - None, - None, - ); - let context2 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - Shell { - shell_type: ShellType::Zsh, - shell_path: "/bin/zsh".into(), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }, - None, - None, - None, - None, - ); - - assert!(context1.equals_except_shell(&context2)); - } - - #[test] - fn serialize_environment_context_with_subagents() { - let context = EnvironmentContext::new( - Some(test_path_buf("/repo")), - fake_shell(), - Some("2026-02-26".to_string()), - Some("America/Los_Angeles".to_string()), - None, - Some("- agent-1: atlas\n- agent-2".to_string()), - ); - - let expected = format!( - r#" - {} - bash - 2026-02-26 - America/Los_Angeles - - - agent-1: atlas - - agent-2 - -"#, - test_path_buf("/repo").display() - ); - - assert_eq!(context.serialize_to_xml(), expected); - } -} +#[path = "environment_context_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/environment_context_tests.rs b/codex-rs/core/src/environment_context_tests.rs new file mode 100644 index 0000000000..5718c09de4 --- /dev/null +++ b/codex-rs/core/src/environment_context_tests.rs @@ -0,0 +1,274 @@ +use crate::shell::ShellType; + +use super::*; +use core_test_support::test_path_buf; +use pretty_assertions::assert_eq; + +fn fake_shell() -> Shell { + Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + } +} + +#[test] +fn serialize_workspace_write_environment_context() { + let cwd = test_path_buf("/repo"); + let context = EnvironmentContext::new( + Some(cwd.clone()), + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + None, + ); + + let expected = format!( + r#" + {cwd} + bash + 2026-02-26 + America/Los_Angeles +"#, + cwd = cwd.display(), + ); + + assert_eq!(context.serialize_to_xml(), expected); +} + +#[test] +fn serialize_environment_context_with_network() { + let network = NetworkContext { + allowed_domains: vec!["api.example.com".to_string(), "*.openai.com".to_string()], + denied_domains: vec!["blocked.example.com".to_string()], + }; + let context = EnvironmentContext::new( + Some(test_path_buf("/repo")), + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + Some(network), + None, + ); + + let expected = format!( + r#" + {} + bash + 2026-02-26 + America/Los_Angeles + + api.example.com + *.openai.com + blocked.example.com + +"#, + test_path_buf("/repo").display() + ); + + assert_eq!(context.serialize_to_xml(), expected); +} + +#[test] +fn serialize_read_only_environment_context() { + let context = EnvironmentContext::new( + None, + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + None, + ); + + let expected = r#" + bash + 2026-02-26 + America/Los_Angeles +"#; + + assert_eq!(context.serialize_to_xml(), expected); +} + +#[test] +fn serialize_external_sandbox_environment_context() { + let context = EnvironmentContext::new( + None, + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + None, + ); + + let expected = r#" + bash + 2026-02-26 + America/Los_Angeles +"#; + + assert_eq!(context.serialize_to_xml(), expected); +} + +#[test] +fn serialize_external_sandbox_with_restricted_network_environment_context() { + let context = EnvironmentContext::new( + None, + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + None, + ); + + let expected = r#" + bash + 2026-02-26 + America/Los_Angeles +"#; + + assert_eq!(context.serialize_to_xml(), expected); +} + +#[test] +fn serialize_full_access_environment_context() { + let context = EnvironmentContext::new( + None, + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + None, + ); + + let expected = r#" + bash + 2026-02-26 + America/Los_Angeles +"#; + + assert_eq!(context.serialize_to_xml(), expected); +} + +#[test] +fn equals_except_shell_compares_cwd() { + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + None, + ); + assert!(context1.equals_except_shell(&context2)); +} + +#[test] +fn equals_except_shell_ignores_sandbox_policy() { + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + None, + ); + + assert!(context1.equals_except_shell(&context2)); +} + +#[test] +fn equals_except_shell_compares_cwd_differences() { + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo1")), + fake_shell(), + None, + None, + None, + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo2")), + fake_shell(), + None, + None, + None, + None, + ); + + assert!(!context1.equals_except_shell(&context2)); +} + +#[test] +fn equals_except_shell_ignores_shell() { + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Shell { + shell_type: ShellType::Bash, + shell_path: "/bin/bash".into(), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }, + None, + None, + None, + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Shell { + shell_type: ShellType::Zsh, + shell_path: "/bin/zsh".into(), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }, + None, + None, + None, + None, + ); + + assert!(context1.equals_except_shell(&context2)); +} + +#[test] +fn serialize_environment_context_with_subagents() { + let context = EnvironmentContext::new( + Some(test_path_buf("/repo")), + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + Some("- agent-1: atlas\n- agent-2".to_string()), + ); + + let expected = format!( + r#" + {} + bash + 2026-02-26 + America/Los_Angeles + + - agent-1: atlas + - agent-2 + +"#, + test_path_buf("/repo").display() + ); + + assert_eq!(context.serialize_to_xml(), expected); +} diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index ad49c611fc..f3bb4dc8e5 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -655,492 +655,5 @@ pub fn get_error_message_ui(e: &CodexErr) -> String { } #[cfg(test)] -mod tests { - use super::*; - use crate::exec::StreamOutput; - use chrono::DateTime; - use chrono::Duration as ChronoDuration; - use chrono::TimeZone; - use chrono::Utc; - use codex_protocol::protocol::RateLimitWindow; - use pretty_assertions::assert_eq; - use reqwest::Response; - use reqwest::ResponseBuilderExt; - use reqwest::StatusCode; - use reqwest::Url; - - fn rate_limit_snapshot() -> RateLimitSnapshot { - let primary_reset_at = Utc - .with_ymd_and_hms(2024, 1, 1, 1, 0, 0) - .unwrap() - .timestamp(); - let secondary_reset_at = Utc - .with_ymd_and_hms(2024, 1, 1, 2, 0, 0) - .unwrap() - .timestamp(); - RateLimitSnapshot { - limit_id: None, - limit_name: None, - primary: Some(RateLimitWindow { - used_percent: 50.0, - window_minutes: Some(60), - resets_at: Some(primary_reset_at), - }), - secondary: Some(RateLimitWindow { - used_percent: 30.0, - window_minutes: Some(120), - resets_at: Some(secondary_reset_at), - }), - credits: None, - plan_type: None, - } - } - - fn with_now_override(now: DateTime, f: impl FnOnce() -> T) -> T { - NOW_OVERRIDE.with(|cell| { - *cell.borrow_mut() = Some(now); - let result = f(); - *cell.borrow_mut() = None; - result - }) - } - - #[test] - fn usage_limit_reached_error_formats_plus_plan() { - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Plus)), - resets_at: None, - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later." - ); - } - - #[test] - fn server_overloaded_maps_to_protocol() { - let err = CodexErr::ServerOverloaded; - assert_eq!( - err.to_codex_protocol_error(), - CodexErrorInfo::ServerOverloaded - ); - } - - #[test] - fn sandbox_denied_uses_aggregated_output_when_stderr_empty() { - let output = ExecToolCallOutput { - exit_code: 77, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new("aggregate detail".to_string()), - duration: Duration::from_millis(10), - timed_out: false, - }; - let err = CodexErr::Sandbox(SandboxErr::Denied { - output: Box::new(output), - network_policy_decision: None, - }); - assert_eq!(get_error_message_ui(&err), "aggregate detail"); - } - - #[test] - fn sandbox_denied_reports_both_streams_when_available() { - let output = ExecToolCallOutput { - exit_code: 9, - stdout: StreamOutput::new("stdout detail".to_string()), - stderr: StreamOutput::new("stderr detail".to_string()), - aggregated_output: StreamOutput::new(String::new()), - duration: Duration::from_millis(10), - timed_out: false, - }; - let err = CodexErr::Sandbox(SandboxErr::Denied { - output: Box::new(output), - network_policy_decision: None, - }); - assert_eq!(get_error_message_ui(&err), "stderr detail\nstdout detail"); - } - - #[test] - fn sandbox_denied_reports_stdout_when_no_stderr() { - let output = ExecToolCallOutput { - exit_code: 11, - stdout: StreamOutput::new("stdout only".to_string()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(String::new()), - duration: Duration::from_millis(8), - timed_out: false, - }; - let err = CodexErr::Sandbox(SandboxErr::Denied { - output: Box::new(output), - network_policy_decision: None, - }); - assert_eq!(get_error_message_ui(&err), "stdout only"); - } - - #[test] - fn to_error_event_handles_response_stream_failed() { - let response = http::Response::builder() - .status(StatusCode::TOO_MANY_REQUESTS) - .url(Url::parse("http://example.com").unwrap()) - .body("") - .unwrap(); - let source = Response::from(response).error_for_status_ref().unwrap_err(); - let err = CodexErr::ResponseStreamFailed(ResponseStreamFailed { - source, - request_id: Some("req-123".to_string()), - }); - - let event = err.to_error_event(Some("prefix".to_string())); - - assert_eq!( - event.message, - "prefix: Error while reading the server response: HTTP status client error (429 Too Many Requests) for url (http://example.com/), request id: req-123" - ); - assert_eq!( - event.codex_error_info, - Some(CodexErrorInfo::ResponseStreamConnectionFailed { - http_status_code: Some(429) - }) - ); - } - - #[test] - fn sandbox_denied_reports_exit_code_when_no_output_available() { - let output = ExecToolCallOutput { - exit_code: 13, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(String::new()), - duration: Duration::from_millis(5), - timed_out: false, - }; - let err = CodexErr::Sandbox(SandboxErr::Denied { - output: Box::new(output), - network_policy_decision: None, - }); - assert_eq!( - get_error_message_ui(&err), - "command failed inside sandbox with exit code 13" - ); - } - - #[test] - fn usage_limit_reached_error_formats_free_plan() { - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Free)), - resets_at: None, - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again later." - ); - } - - #[test] - fn usage_limit_reached_error_formats_go_plan() { - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Go)), - resets_at: None, - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again later." - ); - } - - #[test] - fn usage_limit_reached_error_formats_default_when_none() { - let err = UsageLimitReachedError { - plan_type: None, - resets_at: None, - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again later." - ); - } - - #[test] - fn usage_limit_reached_error_formats_team_plan() { - let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); - let resets_at = base + ChronoDuration::hours(1); - with_now_override(base, move || { - let expected_time = format_retry_timestamp(&resets_at); - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Team)), - resets_at: Some(resets_at), - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - let expected = format!( - "You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}." - ); - assert_eq!(err.to_string(), expected); - }); - } - - #[test] - fn usage_limit_reached_error_formats_business_plan_without_reset() { - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Business)), - resets_at: None, - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. To get more access now, send a request to your admin or try again later." - ); - } - - #[test] - fn usage_limit_reached_error_formats_default_for_other_plans() { - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Enterprise)), - resets_at: None, - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again later." - ); - } - - #[test] - fn usage_limit_reached_error_formats_pro_plan_with_reset() { - let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); - let resets_at = base + ChronoDuration::hours(1); - with_now_override(base, move || { - let expected_time = format_retry_timestamp(&resets_at); - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Pro)), - resets_at: Some(resets_at), - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - let expected = format!( - "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." - ); - assert_eq!(err.to_string(), expected); - }); - } - - #[test] - fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() { - let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); - let resets_at = base + ChronoDuration::hours(1); - with_now_override(base, move || { - let expected_time = format_retry_timestamp(&resets_at); - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Plus)), - resets_at: Some(resets_at), - rate_limits: Some(Box::new(RateLimitSnapshot { - limit_id: Some("codex_other".to_string()), - limit_name: Some("codex_other".to_string()), - ..rate_limit_snapshot() - })), - promo_message: Some( - "Visit https://chatgpt.com/codex/settings/usage to purchase more credits" - .to_string(), - ), - }; - let expected = format!( - "You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}." - ); - assert_eq!(err.to_string(), expected); - }); - } - - #[test] - fn usage_limit_reached_includes_minutes_when_available() { - let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); - let resets_at = base + ChronoDuration::minutes(5); - with_now_override(base, move || { - let expected_time = format_retry_timestamp(&resets_at); - let err = UsageLimitReachedError { - plan_type: None, - resets_at: Some(resets_at), - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - let expected = format!("You've hit your usage limit. Try again at {expected_time}."); - assert_eq!(err.to_string(), expected); - }); - } - - #[test] - fn unexpected_status_cloudflare_html_is_simplified() { - let err = UnexpectedResponseError { - status: StatusCode::FORBIDDEN, - body: "Cloudflare error: Sorry, you have been blocked" - .to_string(), - url: Some("http://example.com/blocked".to_string()), - cf_ray: Some("ray-id".to_string()), - request_id: None, - }; - let status = StatusCode::FORBIDDEN.to_string(); - let url = "http://example.com/blocked"; - assert_eq!( - err.to_string(), - format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, cf-ray: ray-id") - ); - } - - #[test] - fn unexpected_status_non_html_is_unchanged() { - let err = UnexpectedResponseError { - status: StatusCode::FORBIDDEN, - body: "plain text error".to_string(), - url: Some("http://example.com/plain".to_string()), - cf_ray: None, - request_id: None, - }; - let status = StatusCode::FORBIDDEN.to_string(); - let url = "http://example.com/plain"; - assert_eq!( - err.to_string(), - format!("unexpected status {status}: plain text error, url: {url}") - ); - } - - #[test] - fn unexpected_status_prefers_error_message_when_present() { - let err = UnexpectedResponseError { - status: StatusCode::UNAUTHORIZED, - body: r#"{"error":{"message":"Workspace is not authorized in this region."},"status":401}"# - .to_string(), - url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), - cf_ray: None, - request_id: Some("req-123".to_string()), - }; - let status = StatusCode::UNAUTHORIZED.to_string(); - assert_eq!( - err.to_string(), - format!( - "unexpected status {status}: Workspace is not authorized in this region., url: https://chatgpt.com/backend-api/codex/responses, request id: req-123" - ) - ); - } - - #[test] - fn unexpected_status_truncates_long_body_with_ellipsis() { - let long_body = "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES + 10); - let err = UnexpectedResponseError { - status: StatusCode::BAD_GATEWAY, - body: long_body, - url: Some("http://example.com/long".to_string()), - cf_ray: None, - request_id: Some("req-long".to_string()), - }; - let status = StatusCode::BAD_GATEWAY.to_string(); - let expected_body = format!("{}...", "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES)); - assert_eq!( - err.to_string(), - format!( - "unexpected status {status}: {expected_body}, url: http://example.com/long, request id: req-long" - ) - ); - } - - #[test] - fn unexpected_status_includes_cf_ray_and_request_id() { - let err = UnexpectedResponseError { - status: StatusCode::UNAUTHORIZED, - body: "plain text error".to_string(), - url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), - cf_ray: Some("9c81f9f18f2fa49d-LHR".to_string()), - request_id: Some("req-xyz".to_string()), - }; - let status = StatusCode::UNAUTHORIZED.to_string(); - assert_eq!( - err.to_string(), - format!( - "unexpected status {status}: plain text error, url: https://chatgpt.com/backend-api/codex/responses, cf-ray: 9c81f9f18f2fa49d-LHR, request id: req-xyz" - ) - ); - } - - #[test] - fn usage_limit_reached_includes_hours_and_minutes() { - let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); - let resets_at = base + ChronoDuration::hours(3) + ChronoDuration::minutes(32); - with_now_override(base, move || { - let expected_time = format_retry_timestamp(&resets_at); - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Plus)), - resets_at: Some(resets_at), - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - let expected = format!( - "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." - ); - assert_eq!(err.to_string(), expected); - }); - } - - #[test] - fn usage_limit_reached_includes_days_hours_minutes() { - let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); - let resets_at = - base + ChronoDuration::days(2) + ChronoDuration::hours(3) + ChronoDuration::minutes(5); - with_now_override(base, move || { - let expected_time = format_retry_timestamp(&resets_at); - let err = UsageLimitReachedError { - plan_type: None, - resets_at: Some(resets_at), - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - let expected = format!("You've hit your usage limit. Try again at {expected_time}."); - assert_eq!(err.to_string(), expected); - }); - } - - #[test] - fn usage_limit_reached_less_than_minute() { - let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); - let resets_at = base + ChronoDuration::seconds(30); - with_now_override(base, move || { - let expected_time = format_retry_timestamp(&resets_at); - let err = UsageLimitReachedError { - plan_type: None, - resets_at: Some(resets_at), - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: None, - }; - let expected = format!("You've hit your usage limit. Try again at {expected_time}."); - assert_eq!(err.to_string(), expected); - }); - } - - #[test] - fn usage_limit_reached_with_promo_message() { - let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); - let resets_at = base + ChronoDuration::seconds(30); - with_now_override(base, move || { - let expected_time = format_retry_timestamp(&resets_at); - let err = UsageLimitReachedError { - plan_type: None, - resets_at: Some(resets_at), - rate_limits: Some(Box::new(rate_limit_snapshot())), - promo_message: Some( - "To continue using Codex, start a free trial of today".to_string(), - ), - }; - let expected = format!( - "You've hit your usage limit. To continue using Codex, start a free trial of today, or try again at {expected_time}." - ); - assert_eq!(err.to_string(), expected); - }); - } -} +#[path = "error_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/error_tests.rs b/codex-rs/core/src/error_tests.rs new file mode 100644 index 0000000000..fa2bd4a6e1 --- /dev/null +++ b/codex-rs/core/src/error_tests.rs @@ -0,0 +1,487 @@ +use super::*; +use crate::exec::StreamOutput; +use chrono::DateTime; +use chrono::Duration as ChronoDuration; +use chrono::TimeZone; +use chrono::Utc; +use codex_protocol::protocol::RateLimitWindow; +use pretty_assertions::assert_eq; +use reqwest::Response; +use reqwest::ResponseBuilderExt; +use reqwest::StatusCode; +use reqwest::Url; + +fn rate_limit_snapshot() -> RateLimitSnapshot { + let primary_reset_at = Utc + .with_ymd_and_hms(2024, 1, 1, 1, 0, 0) + .unwrap() + .timestamp(); + let secondary_reset_at = Utc + .with_ymd_and_hms(2024, 1, 1, 2, 0, 0) + .unwrap() + .timestamp(); + RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 50.0, + window_minutes: Some(60), + resets_at: Some(primary_reset_at), + }), + secondary: Some(RateLimitWindow { + used_percent: 30.0, + window_minutes: Some(120), + resets_at: Some(secondary_reset_at), + }), + credits: None, + plan_type: None, + } +} + +fn with_now_override(now: DateTime, f: impl FnOnce() -> T) -> T { + NOW_OVERRIDE.with(|cell| { + *cell.borrow_mut() = Some(now); + let result = f(); + *cell.borrow_mut() = None; + result + }) +} + +#[test] +fn usage_limit_reached_error_formats_plus_plan() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Plus)), + resets_at: None, + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later." + ); +} + +#[test] +fn server_overloaded_maps_to_protocol() { + let err = CodexErr::ServerOverloaded; + assert_eq!( + err.to_codex_protocol_error(), + CodexErrorInfo::ServerOverloaded + ); +} + +#[test] +fn sandbox_denied_uses_aggregated_output_when_stderr_empty() { + let output = ExecToolCallOutput { + exit_code: 77, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new("aggregate detail".to_string()), + duration: Duration::from_millis(10), + timed_out: false, + }; + let err = CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + network_policy_decision: None, + }); + assert_eq!(get_error_message_ui(&err), "aggregate detail"); +} + +#[test] +fn sandbox_denied_reports_both_streams_when_available() { + let output = ExecToolCallOutput { + exit_code: 9, + stdout: StreamOutput::new("stdout detail".to_string()), + stderr: StreamOutput::new("stderr detail".to_string()), + aggregated_output: StreamOutput::new(String::new()), + duration: Duration::from_millis(10), + timed_out: false, + }; + let err = CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + network_policy_decision: None, + }); + assert_eq!(get_error_message_ui(&err), "stderr detail\nstdout detail"); +} + +#[test] +fn sandbox_denied_reports_stdout_when_no_stderr() { + let output = ExecToolCallOutput { + exit_code: 11, + stdout: StreamOutput::new("stdout only".to_string()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(String::new()), + duration: Duration::from_millis(8), + timed_out: false, + }; + let err = CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + network_policy_decision: None, + }); + assert_eq!(get_error_message_ui(&err), "stdout only"); +} + +#[test] +fn to_error_event_handles_response_stream_failed() { + let response = http::Response::builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .url(Url::parse("http://example.com").unwrap()) + .body("") + .unwrap(); + let source = Response::from(response).error_for_status_ref().unwrap_err(); + let err = CodexErr::ResponseStreamFailed(ResponseStreamFailed { + source, + request_id: Some("req-123".to_string()), + }); + + let event = err.to_error_event(Some("prefix".to_string())); + + assert_eq!( + event.message, + "prefix: Error while reading the server response: HTTP status client error (429 Too Many Requests) for url (http://example.com/), request id: req-123" + ); + assert_eq!( + event.codex_error_info, + Some(CodexErrorInfo::ResponseStreamConnectionFailed { + http_status_code: Some(429) + }) + ); +} + +#[test] +fn sandbox_denied_reports_exit_code_when_no_output_available() { + let output = ExecToolCallOutput { + exit_code: 13, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(String::new()), + duration: Duration::from_millis(5), + timed_out: false, + }; + let err = CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + network_policy_decision: None, + }); + assert_eq!( + get_error_message_ui(&err), + "command failed inside sandbox with exit code 13" + ); +} + +#[test] +fn usage_limit_reached_error_formats_free_plan() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Free)), + resets_at: None, + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again later." + ); +} + +#[test] +fn usage_limit_reached_error_formats_go_plan() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Go)), + resets_at: None, + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again later." + ); +} + +#[test] +fn usage_limit_reached_error_formats_default_when_none() { + let err = UsageLimitReachedError { + plan_type: None, + resets_at: None, + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again later." + ); +} + +#[test] +fn usage_limit_reached_error_formats_team_plan() { + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::hours(1); + with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Team)), + resets_at: Some(resets_at), + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + let expected = format!( + "You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}." + ); + assert_eq!(err.to_string(), expected); + }); +} + +#[test] +fn usage_limit_reached_error_formats_business_plan_without_reset() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Business)), + resets_at: None, + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. To get more access now, send a request to your admin or try again later." + ); +} + +#[test] +fn usage_limit_reached_error_formats_default_for_other_plans() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Enterprise)), + resets_at: None, + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again later." + ); +} + +#[test] +fn usage_limit_reached_error_formats_pro_plan_with_reset() { + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::hours(1); + with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Pro)), + resets_at: Some(resets_at), + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + let expected = format!( + "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." + ); + assert_eq!(err.to_string(), expected); + }); +} + +#[test] +fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() { + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::hours(1); + with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Plus)), + resets_at: Some(resets_at), + rate_limits: Some(Box::new(RateLimitSnapshot { + limit_id: Some("codex_other".to_string()), + limit_name: Some("codex_other".to_string()), + ..rate_limit_snapshot() + })), + promo_message: Some( + "Visit https://chatgpt.com/codex/settings/usage to purchase more credits" + .to_string(), + ), + }; + let expected = format!( + "You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}." + ); + assert_eq!(err.to_string(), expected); + }); +} + +#[test] +fn usage_limit_reached_includes_minutes_when_available() { + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::minutes(5); + with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + let expected = format!("You've hit your usage limit. Try again at {expected_time}."); + assert_eq!(err.to_string(), expected); + }); +} + +#[test] +fn unexpected_status_cloudflare_html_is_simplified() { + let err = UnexpectedResponseError { + status: StatusCode::FORBIDDEN, + body: "Cloudflare error: Sorry, you have been blocked" + .to_string(), + url: Some("http://example.com/blocked".to_string()), + cf_ray: Some("ray-id".to_string()), + request_id: None, + }; + let status = StatusCode::FORBIDDEN.to_string(); + let url = "http://example.com/blocked"; + assert_eq!( + err.to_string(), + format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, cf-ray: ray-id") + ); +} + +#[test] +fn unexpected_status_non_html_is_unchanged() { + let err = UnexpectedResponseError { + status: StatusCode::FORBIDDEN, + body: "plain text error".to_string(), + url: Some("http://example.com/plain".to_string()), + cf_ray: None, + request_id: None, + }; + let status = StatusCode::FORBIDDEN.to_string(); + let url = "http://example.com/plain"; + assert_eq!( + err.to_string(), + format!("unexpected status {status}: plain text error, url: {url}") + ); +} + +#[test] +fn unexpected_status_prefers_error_message_when_present() { + let err = UnexpectedResponseError { + status: StatusCode::UNAUTHORIZED, + body: r#"{"error":{"message":"Workspace is not authorized in this region."},"status":401}"# + .to_string(), + url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), + cf_ray: None, + request_id: Some("req-123".to_string()), + }; + let status = StatusCode::UNAUTHORIZED.to_string(); + assert_eq!( + err.to_string(), + format!( + "unexpected status {status}: Workspace is not authorized in this region., url: https://chatgpt.com/backend-api/codex/responses, request id: req-123" + ) + ); +} + +#[test] +fn unexpected_status_truncates_long_body_with_ellipsis() { + let long_body = "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES + 10); + let err = UnexpectedResponseError { + status: StatusCode::BAD_GATEWAY, + body: long_body, + url: Some("http://example.com/long".to_string()), + cf_ray: None, + request_id: Some("req-long".to_string()), + }; + let status = StatusCode::BAD_GATEWAY.to_string(); + let expected_body = format!("{}...", "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES)); + assert_eq!( + err.to_string(), + format!( + "unexpected status {status}: {expected_body}, url: http://example.com/long, request id: req-long" + ) + ); +} + +#[test] +fn unexpected_status_includes_cf_ray_and_request_id() { + let err = UnexpectedResponseError { + status: StatusCode::UNAUTHORIZED, + body: "plain text error".to_string(), + url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), + cf_ray: Some("9c81f9f18f2fa49d-LHR".to_string()), + request_id: Some("req-xyz".to_string()), + }; + let status = StatusCode::UNAUTHORIZED.to_string(); + assert_eq!( + err.to_string(), + format!( + "unexpected status {status}: plain text error, url: https://chatgpt.com/backend-api/codex/responses, cf-ray: 9c81f9f18f2fa49d-LHR, request id: req-xyz" + ) + ); +} + +#[test] +fn usage_limit_reached_includes_hours_and_minutes() { + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::hours(3) + ChronoDuration::minutes(32); + with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Plus)), + resets_at: Some(resets_at), + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + let expected = format!( + "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." + ); + assert_eq!(err.to_string(), expected); + }); +} + +#[test] +fn usage_limit_reached_includes_days_hours_minutes() { + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = + base + ChronoDuration::days(2) + ChronoDuration::hours(3) + ChronoDuration::minutes(5); + with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + let expected = format!("You've hit your usage limit. Try again at {expected_time}."); + assert_eq!(err.to_string(), expected); + }); +} + +#[test] +fn usage_limit_reached_less_than_minute() { + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::seconds(30); + with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + let expected = format!("You've hit your usage limit. Try again at {expected_time}."); + assert_eq!(err.to_string(), expected); + }); +} + +#[test] +fn usage_limit_reached_with_promo_message() { + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::seconds(30); + with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: Some( + "To continue using Codex, start a free trial of today".to_string(), + ), + }; + let expected = format!( + "You've hit your usage limit. To continue using Codex, start a free trial of today, or try again at {expected_time}." + ); + assert_eq!(err.to_string(), expected); + }); +} diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 2207ce0a86..e1043d4604 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -169,424 +169,5 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { } #[cfg(test)] -mod tests { - use super::parse_turn_item; - use codex_protocol::items::AgentMessageContent; - use codex_protocol::items::TurnItem; - use codex_protocol::items::WebSearchItem; - use codex_protocol::models::ContentItem; - use codex_protocol::models::ReasoningItemContent; - use codex_protocol::models::ReasoningItemReasoningSummary; - use codex_protocol::models::ResponseItem; - use codex_protocol::models::WebSearchAction; - use codex_protocol::user_input::UserInput; - use pretty_assertions::assert_eq; - - #[test] - fn parses_user_message_with_text_and_two_images() { - let img1 = "https://example.com/one.png".to_string(); - let img2 = "https://example.com/two.jpg".to_string(); - - let item = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ - ContentItem::InputText { - text: "Hello world".to_string(), - }, - ContentItem::InputImage { - image_url: img1.clone(), - }, - ContentItem::InputImage { - image_url: img2.clone(), - }, - ], - metadata: None, - end_turn: None, - phase: None, - }; - - let turn_item = parse_turn_item(&item).expect("expected user message turn item"); - - match turn_item { - TurnItem::UserMessage(user) => { - let expected_content = vec![ - UserInput::Text { - text: "Hello world".to_string(), - text_elements: Vec::new(), - }, - UserInput::Image { image_url: img1 }, - UserInput::Image { image_url: img2 }, - ]; - assert_eq!(user.content, expected_content); - } - other => panic!("expected TurnItem::UserMessage, got {other:?}"), - } - } - - #[test] - fn skips_local_image_label_text() { - let image_url = "data:image/png;base64,abc".to_string(); - let label = codex_protocol::models::local_image_open_tag_text(1); - let user_text = "Please review this image.".to_string(); - - let item = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ - ContentItem::InputText { text: label }, - ContentItem::InputImage { - image_url: image_url.clone(), - }, - ContentItem::InputText { - text: "".to_string(), - }, - ContentItem::InputText { - text: user_text.clone(), - }, - ], - metadata: None, - end_turn: None, - phase: None, - }; - - let turn_item = parse_turn_item(&item).expect("expected user message turn item"); - - match turn_item { - TurnItem::UserMessage(user) => { - let expected_content = vec![ - UserInput::Image { image_url }, - UserInput::Text { - text: user_text, - text_elements: Vec::new(), - }, - ]; - assert_eq!(user.content, expected_content); - } - other => panic!("expected TurnItem::UserMessage, got {other:?}"), - } - } - - #[test] - fn skips_unnamed_image_label_text() { - let image_url = "data:image/png;base64,abc".to_string(); - let label = codex_protocol::models::image_open_tag_text(); - let user_text = "Please review this image.".to_string(); - - let item = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ - ContentItem::InputText { text: label }, - ContentItem::InputImage { - image_url: image_url.clone(), - }, - ContentItem::InputText { - text: codex_protocol::models::image_close_tag_text(), - }, - ContentItem::InputText { - text: user_text.clone(), - }, - ], - metadata: None, - end_turn: None, - phase: None, - }; - - let turn_item = parse_turn_item(&item).expect("expected user message turn item"); - - match turn_item { - TurnItem::UserMessage(user) => { - let expected_content = vec![ - UserInput::Image { image_url }, - UserInput::Text { - text: user_text, - text_elements: Vec::new(), - }, - ]; - assert_eq!(user.content, expected_content); - } - other => panic!("expected TurnItem::UserMessage, got {other:?}"), - } - } - - #[test] - fn skips_user_instructions_and_env() { - let items = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "test_text".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "\ndemo\nskills/demo/SKILL.md\nbody\n" - .to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "echo 42".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ - ContentItem::InputText { - text: "ctx".to_string(), - }, - ContentItem::InputText { - text: - "# AGENTS.md instructions for dir\n\n\nbody\n" - .to_string(), - }, - ], - metadata: None, - end_turn: None, - phase: None, - }, - ]; - - for item in items { - let turn_item = parse_turn_item(&item); - assert!(turn_item.is_none(), "expected none, got {turn_item:?}"); - } - } - - #[test] - fn parses_agent_message() { - let item = ResponseItem::Message { - id: Some("msg-1".to_string()), - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "Hello from Codex".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - }; - - let turn_item = parse_turn_item(&item).expect("expected agent message turn item"); - - match turn_item { - TurnItem::AgentMessage(message) => { - let Some(AgentMessageContent::Text { text }) = message.content.first() else { - panic!("expected agent message text content"); - }; - assert_eq!(text, "Hello from Codex"); - } - other => panic!("expected TurnItem::AgentMessage, got {other:?}"), - } - } - - #[test] - fn parses_reasoning_summary_and_raw_content() { - let item = ResponseItem::Reasoning { - id: "reasoning_1".to_string(), - summary: vec![ - ReasoningItemReasoningSummary::SummaryText { - text: "Step 1".to_string(), - }, - ReasoningItemReasoningSummary::SummaryText { - text: "Step 2".to_string(), - }, - ], - content: Some(vec![ReasoningItemContent::ReasoningText { - text: "raw details".to_string(), - }]), - encrypted_content: None, - }; - - let turn_item = parse_turn_item(&item).expect("expected reasoning turn item"); - - match turn_item { - TurnItem::Reasoning(reasoning) => { - assert_eq!( - reasoning.summary_text, - vec!["Step 1".to_string(), "Step 2".to_string()] - ); - assert_eq!(reasoning.raw_content, vec!["raw details".to_string()]); - } - other => panic!("expected TurnItem::Reasoning, got {other:?}"), - } - } - - #[test] - fn parses_reasoning_including_raw_content() { - let item = ResponseItem::Reasoning { - id: "reasoning_2".to_string(), - summary: vec![ReasoningItemReasoningSummary::SummaryText { - text: "Summarized step".to_string(), - }], - content: Some(vec![ - ReasoningItemContent::ReasoningText { - text: "raw step".to_string(), - }, - ReasoningItemContent::Text { - text: "final thought".to_string(), - }, - ]), - encrypted_content: None, - }; - - let turn_item = parse_turn_item(&item).expect("expected reasoning turn item"); - - match turn_item { - TurnItem::Reasoning(reasoning) => { - assert_eq!(reasoning.summary_text, vec!["Summarized step".to_string()]); - assert_eq!( - reasoning.raw_content, - vec!["raw step".to_string(), "final thought".to_string()] - ); - } - other => panic!("expected TurnItem::Reasoning, got {other:?}"), - } - } - - #[test] - fn parses_web_search_call() { - let item = ResponseItem::WebSearchCall { - id: Some("ws_1".to_string()), - status: Some("completed".to_string()), - action: Some(WebSearchAction::Search { - query: Some("weather".to_string()), - queries: None, - }), - }; - - let turn_item = parse_turn_item(&item).expect("expected web search turn item"); - - match turn_item { - TurnItem::WebSearch(search) => assert_eq!( - search, - WebSearchItem { - id: "ws_1".to_string(), - query: "weather".to_string(), - action: WebSearchAction::Search { - query: Some("weather".to_string()), - queries: None, - }, - metadata: None, - } - ), - other => panic!("expected TurnItem::WebSearch, got {other:?}"), - } - } - - #[test] - fn parses_web_search_open_page_call() { - let item = ResponseItem::WebSearchCall { - id: Some("ws_open".to_string()), - status: Some("completed".to_string()), - action: Some(WebSearchAction::OpenPage { - url: Some("https://example.com".to_string()), - }), - }; - - let turn_item = parse_turn_item(&item).expect("expected web search turn item"); - - match turn_item { - TurnItem::WebSearch(search) => assert_eq!( - search, - WebSearchItem { - id: "ws_open".to_string(), - query: "https://example.com".to_string(), - action: WebSearchAction::OpenPage { - url: Some("https://example.com".to_string()), - }, - metadata: None, - } - ), - other => panic!("expected TurnItem::WebSearch, got {other:?}"), - } - } - - #[test] - fn parses_web_search_find_in_page_call() { - let item = ResponseItem::WebSearchCall { - id: Some("ws_find".to_string()), - status: Some("completed".to_string()), - action: Some(WebSearchAction::FindInPage { - url: Some("https://example.com".to_string()), - pattern: Some("needle".to_string()), - }), - }; - - let turn_item = parse_turn_item(&item).expect("expected web search turn item"); - - match turn_item { - TurnItem::WebSearch(search) => assert_eq!( - search, - WebSearchItem { - id: "ws_find".to_string(), - query: "'needle' in https://example.com".to_string(), - action: WebSearchAction::FindInPage { - url: Some("https://example.com".to_string()), - pattern: Some("needle".to_string()), - }, - metadata: None, - } - ), - other => panic!("expected TurnItem::WebSearch, got {other:?}"), - } - } - - #[test] - fn parses_partial_web_search_call_without_action_as_other() { - let item = ResponseItem::WebSearchCall { - id: Some("ws_partial".to_string()), - status: Some("in_progress".to_string()), - action: None, - }; - - let turn_item = parse_turn_item(&item).expect("expected web search turn item"); - match turn_item { - TurnItem::WebSearch(search) => assert_eq!( - search, - WebSearchItem { - id: "ws_partial".to_string(), - query: String::new(), - action: WebSearchAction::Other, - metadata: None, - } - ), - other => panic!("expected TurnItem::WebSearch, got {other:?}"), - } - } -} +#[path = "event_mapping_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs new file mode 100644 index 0000000000..2546ca53d3 --- /dev/null +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -0,0 +1,419 @@ +use super::parse_turn_item; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::TurnItem; +use codex_protocol::items::WebSearchItem; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ReasoningItemContent; +use codex_protocol::models::ReasoningItemReasoningSummary; +use codex_protocol::models::ResponseItem; +use codex_protocol::models::WebSearchAction; +use codex_protocol::user_input::UserInput; +use pretty_assertions::assert_eq; + +#[test] +fn parses_user_message_with_text_and_two_images() { + let img1 = "https://example.com/one.png".to_string(); + let img2 = "https://example.com/two.jpg".to_string(); + + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "Hello world".to_string(), + }, + ContentItem::InputImage { + image_url: img1.clone(), + }, + ContentItem::InputImage { + image_url: img2.clone(), + }, + ], + metadata: None, + end_turn: None, + phase: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected user message turn item"); + + match turn_item { + TurnItem::UserMessage(user) => { + let expected_content = vec![ + UserInput::Text { + text: "Hello world".to_string(), + text_elements: Vec::new(), + }, + UserInput::Image { image_url: img1 }, + UserInput::Image { image_url: img2 }, + ]; + assert_eq!(user.content, expected_content); + } + other => panic!("expected TurnItem::UserMessage, got {other:?}"), + } +} + +#[test] +fn skips_local_image_label_text() { + let image_url = "data:image/png;base64,abc".to_string(); + let label = codex_protocol::models::local_image_open_tag_text(1); + let user_text = "Please review this image.".to_string(); + + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { text: label }, + ContentItem::InputImage { + image_url: image_url.clone(), + }, + ContentItem::InputText { + text: "".to_string(), + }, + ContentItem::InputText { + text: user_text.clone(), + }, + ], + metadata: None, + end_turn: None, + phase: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected user message turn item"); + + match turn_item { + TurnItem::UserMessage(user) => { + let expected_content = vec![ + UserInput::Image { image_url }, + UserInput::Text { + text: user_text, + text_elements: Vec::new(), + }, + ]; + assert_eq!(user.content, expected_content); + } + other => panic!("expected TurnItem::UserMessage, got {other:?}"), + } +} + +#[test] +fn skips_unnamed_image_label_text() { + let image_url = "data:image/png;base64,abc".to_string(); + let label = codex_protocol::models::image_open_tag_text(); + let user_text = "Please review this image.".to_string(); + + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { text: label }, + ContentItem::InputImage { + image_url: image_url.clone(), + }, + ContentItem::InputText { + text: codex_protocol::models::image_close_tag_text(), + }, + ContentItem::InputText { + text: user_text.clone(), + }, + ], + metadata: None, + end_turn: None, + phase: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected user message turn item"); + + match turn_item { + TurnItem::UserMessage(user) => { + let expected_content = vec![ + UserInput::Image { image_url }, + UserInput::Text { + text: user_text, + text_elements: Vec::new(), + }, + ]; + assert_eq!(user.content, expected_content); + } + other => panic!("expected TurnItem::UserMessage, got {other:?}"), + } +} + +#[test] +fn skips_user_instructions_and_env() { + let items = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "test_text".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\ndemo\nskills/demo/SKILL.md\nbody\n" + .to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "echo 42".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "ctx".to_string(), + }, + ContentItem::InputText { + text: + "# AGENTS.md instructions for dir\n\n\nbody\n" + .to_string(), + }, + ], + metadata: None, + end_turn: None, + phase: None, + }, + ]; + + for item in items { + let turn_item = parse_turn_item(&item); + assert!(turn_item.is_none(), "expected none, got {turn_item:?}"); + } +} + +#[test] +fn parses_agent_message() { + let item = ResponseItem::Message { + id: Some("msg-1".to_string()), + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "Hello from Codex".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected agent message turn item"); + + match turn_item { + TurnItem::AgentMessage(message) => { + let Some(AgentMessageContent::Text { text }) = message.content.first() else { + panic!("expected agent message text content"); + }; + assert_eq!(text, "Hello from Codex"); + } + other => panic!("expected TurnItem::AgentMessage, got {other:?}"), + } +} + +#[test] +fn parses_reasoning_summary_and_raw_content() { + let item = ResponseItem::Reasoning { + id: "reasoning_1".to_string(), + summary: vec![ + ReasoningItemReasoningSummary::SummaryText { + text: "Step 1".to_string(), + }, + ReasoningItemReasoningSummary::SummaryText { + text: "Step 2".to_string(), + }, + ], + content: Some(vec![ReasoningItemContent::ReasoningText { + text: "raw details".to_string(), + }]), + encrypted_content: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected reasoning turn item"); + + match turn_item { + TurnItem::Reasoning(reasoning) => { + assert_eq!( + reasoning.summary_text, + vec!["Step 1".to_string(), "Step 2".to_string()] + ); + assert_eq!(reasoning.raw_content, vec!["raw details".to_string()]); + } + other => panic!("expected TurnItem::Reasoning, got {other:?}"), + } +} + +#[test] +fn parses_reasoning_including_raw_content() { + let item = ResponseItem::Reasoning { + id: "reasoning_2".to_string(), + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "Summarized step".to_string(), + }], + content: Some(vec![ + ReasoningItemContent::ReasoningText { + text: "raw step".to_string(), + }, + ReasoningItemContent::Text { + text: "final thought".to_string(), + }, + ]), + encrypted_content: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected reasoning turn item"); + + match turn_item { + TurnItem::Reasoning(reasoning) => { + assert_eq!(reasoning.summary_text, vec!["Summarized step".to_string()]); + assert_eq!( + reasoning.raw_content, + vec!["raw step".to_string(), "final thought".to_string()] + ); + } + other => panic!("expected TurnItem::Reasoning, got {other:?}"), + } +} + +#[test] +fn parses_web_search_call() { + let item = ResponseItem::WebSearchCall { + id: Some("ws_1".to_string()), + status: Some("completed".to_string()), + action: Some(WebSearchAction::Search { + query: Some("weather".to_string()), + queries: None, + }), + }; + + let turn_item = parse_turn_item(&item).expect("expected web search turn item"); + + match turn_item { + TurnItem::WebSearch(search) => assert_eq!( + search, + WebSearchItem { + id: "ws_1".to_string(), + query: "weather".to_string(), + action: WebSearchAction::Search { + query: Some("weather".to_string()), + queries: None, + }, + metadata: None, + } + ), + other => panic!("expected TurnItem::WebSearch, got {other:?}"), + } +} + +#[test] +fn parses_web_search_open_page_call() { + let item = ResponseItem::WebSearchCall { + id: Some("ws_open".to_string()), + status: Some("completed".to_string()), + action: Some(WebSearchAction::OpenPage { + url: Some("https://example.com".to_string()), + }), + }; + + let turn_item = parse_turn_item(&item).expect("expected web search turn item"); + + match turn_item { + TurnItem::WebSearch(search) => assert_eq!( + search, + WebSearchItem { + id: "ws_open".to_string(), + query: "https://example.com".to_string(), + action: WebSearchAction::OpenPage { + url: Some("https://example.com".to_string()), + }, + metadata: None, + } + ), + other => panic!("expected TurnItem::WebSearch, got {other:?}"), + } +} + +#[test] +fn parses_web_search_find_in_page_call() { + let item = ResponseItem::WebSearchCall { + id: Some("ws_find".to_string()), + status: Some("completed".to_string()), + action: Some(WebSearchAction::FindInPage { + url: Some("https://example.com".to_string()), + pattern: Some("needle".to_string()), + }), + }; + + let turn_item = parse_turn_item(&item).expect("expected web search turn item"); + + match turn_item { + TurnItem::WebSearch(search) => assert_eq!( + search, + WebSearchItem { + id: "ws_find".to_string(), + query: "'needle' in https://example.com".to_string(), + action: WebSearchAction::FindInPage { + url: Some("https://example.com".to_string()), + pattern: Some("needle".to_string()), + }, + metadata: None, + } + ), + other => panic!("expected TurnItem::WebSearch, got {other:?}"), + } +} + +#[test] +fn parses_partial_web_search_call_without_action_as_other() { + let item = ResponseItem::WebSearchCall { + id: Some("ws_partial".to_string()), + status: Some("in_progress".to_string()), + action: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected web search turn item"); + match turn_item { + TurnItem::WebSearch(search) => assert_eq!( + search, + WebSearchItem { + id: "ws_partial".to_string(), + query: String::new(), + action: WebSearchAction::Other, + metadata: None, + } + ), + other => panic!("expected TurnItem::WebSearch, got {other:?}"), + } +} diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 19d56d71de..867b93ab5a 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -185,7 +185,7 @@ pub async fn process_exec_tool_call( network_sandbox_policy: NetworkSandboxPolicy, sandbox_cwd: &Path, codex_linux_sandbox_exe: &Option, - use_linux_sandbox_bwrap: bool, + use_legacy_landlock: bool, stdout_stream: Option, ) -> Result { let exec_req = build_exec_request( @@ -195,7 +195,7 @@ pub async fn process_exec_tool_call( network_sandbox_policy, sandbox_cwd, codex_linux_sandbox_exe, - use_linux_sandbox_bwrap, + use_legacy_landlock, )?; // Route through the sandboxing module for a single, unified execution path. @@ -211,7 +211,7 @@ pub fn build_exec_request( network_sandbox_policy: NetworkSandboxPolicy, sandbox_cwd: &Path, codex_linux_sandbox_exe: &Option, - use_linux_sandbox_bwrap: bool, + use_legacy_landlock: bool, ) -> Result { let windows_sandbox_level = params.windows_sandbox_level; let enforce_managed_network = params.network.is_some(); @@ -269,7 +269,7 @@ pub fn build_exec_request( #[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: None, codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(), - use_linux_sandbox_bwrap, + use_legacy_landlock, windows_sandbox_level, }) .map_err(CodexErr::from)?; @@ -1003,428 +1003,5 @@ fn synthetic_exit_status(code: i32) -> ExitStatus { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::time::Duration; - use tokio::io::AsyncWriteExt; - - fn make_exec_output( - exit_code: i32, - stdout: &str, - stderr: &str, - aggregated: &str, - ) -> ExecToolCallOutput { - ExecToolCallOutput { - exit_code, - stdout: StreamOutput::new(stdout.to_string()), - stderr: StreamOutput::new(stderr.to_string()), - aggregated_output: StreamOutput::new(aggregated.to_string()), - duration: Duration::from_millis(1), - timed_out: false, - } - } - - #[test] - fn sandbox_detection_requires_keywords() { - let output = make_exec_output(1, "", "", ""); - assert!(!is_likely_sandbox_denied( - SandboxType::LinuxSeccomp, - &output - )); - } - - #[test] - fn sandbox_detection_identifies_keyword_in_stderr() { - let output = make_exec_output(1, "", "Operation not permitted", ""); - assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output)); - } - - #[test] - fn sandbox_detection_respects_quick_reject_exit_codes() { - let output = make_exec_output(127, "", "command not found", ""); - assert!(!is_likely_sandbox_denied( - SandboxType::LinuxSeccomp, - &output - )); - } - - #[test] - fn sandbox_detection_ignores_non_sandbox_mode() { - let output = make_exec_output(1, "", "Operation not permitted", ""); - assert!(!is_likely_sandbox_denied(SandboxType::None, &output)); - } - - #[test] - fn sandbox_detection_ignores_network_policy_text_in_non_sandbox_mode() { - let output = make_exec_output( - 0, - "", - "", - r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","reason":"not_allowed","source":"decider","protocol":"http","host":"google.com","port":80}"#, - ); - assert!(!is_likely_sandbox_denied(SandboxType::None, &output)); - } - - #[test] - fn sandbox_detection_uses_aggregated_output() { - let output = make_exec_output( - 101, - "", - "", - "cargo failed: Read-only file system when writing target", - ); - assert!(is_likely_sandbox_denied( - SandboxType::MacosSeatbelt, - &output - )); - } - - #[test] - fn sandbox_detection_ignores_network_policy_text_with_zero_exit_code() { - let output = make_exec_output( - 0, - "", - "", - r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","source":"decider","protocol":"http","host":"google.com","port":80}"#, - ); - - assert!(!is_likely_sandbox_denied( - SandboxType::LinuxSeccomp, - &output - )); - } - - #[tokio::test] - async fn read_capped_limits_retained_bytes() { - let (mut writer, reader) = tokio::io::duplex(1024); - let bytes = vec![b'a'; EXEC_OUTPUT_MAX_BYTES.saturating_add(128 * 1024)]; - tokio::spawn(async move { - writer.write_all(&bytes).await.expect("write"); - }); - - let out = read_capped(reader, None, false).await.expect("read"); - assert_eq!(out.text.len(), EXEC_OUTPUT_MAX_BYTES); - } - - #[test] - fn aggregate_output_prefers_stderr_on_contention() { - let stdout = StreamOutput { - text: vec![b'a'; EXEC_OUTPUT_MAX_BYTES], - truncated_after_lines: None, - }; - let stderr = StreamOutput { - text: vec![b'b'; EXEC_OUTPUT_MAX_BYTES], - truncated_after_lines: None, - }; - - let aggregated = aggregate_output(&stdout, &stderr); - let stdout_cap = EXEC_OUTPUT_MAX_BYTES / 3; - let stderr_cap = EXEC_OUTPUT_MAX_BYTES.saturating_sub(stdout_cap); - - assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES); - assert_eq!(aggregated.text[..stdout_cap], vec![b'a'; stdout_cap]); - assert_eq!(aggregated.text[stdout_cap..], vec![b'b'; stderr_cap]); - } - - #[test] - fn aggregate_output_fills_remaining_capacity_with_stderr() { - let stdout_len = EXEC_OUTPUT_MAX_BYTES / 10; - let stdout = StreamOutput { - text: vec![b'a'; stdout_len], - truncated_after_lines: None, - }; - let stderr = StreamOutput { - text: vec![b'b'; EXEC_OUTPUT_MAX_BYTES], - truncated_after_lines: None, - }; - - let aggregated = aggregate_output(&stdout, &stderr); - let stderr_cap = EXEC_OUTPUT_MAX_BYTES.saturating_sub(stdout_len); - - assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES); - assert_eq!(aggregated.text[..stdout_len], vec![b'a'; stdout_len]); - assert_eq!(aggregated.text[stdout_len..], vec![b'b'; stderr_cap]); - } - - #[test] - fn aggregate_output_rebalances_when_stderr_is_small() { - let stdout = StreamOutput { - text: vec![b'a'; EXEC_OUTPUT_MAX_BYTES], - truncated_after_lines: None, - }; - let stderr = StreamOutput { - text: vec![b'b'; 1], - truncated_after_lines: None, - }; - - let aggregated = aggregate_output(&stdout, &stderr); - let stdout_len = EXEC_OUTPUT_MAX_BYTES.saturating_sub(1); - - assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES); - assert_eq!(aggregated.text[..stdout_len], vec![b'a'; stdout_len]); - assert_eq!(aggregated.text[stdout_len..], vec![b'b'; 1]); - } - - #[test] - fn aggregate_output_keeps_stdout_then_stderr_when_under_cap() { - let stdout = StreamOutput { - text: vec![b'a'; 4], - truncated_after_lines: None, - }; - let stderr = StreamOutput { - text: vec![b'b'; 3], - truncated_after_lines: None, - }; - - let aggregated = aggregate_output(&stdout, &stderr); - let mut expected = Vec::new(); - expected.extend_from_slice(&stdout.text); - expected.extend_from_slice(&stderr.text); - - assert_eq!(aggregated.text, expected); - assert_eq!(aggregated.truncated_after_lines, None); - } - - #[test] - fn windows_restricted_token_skips_external_sandbox_policies() { - let policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, - }; - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); - - assert_eq!( - should_use_windows_restricted_token_sandbox( - SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - ), - false - ); - } - - #[test] - fn windows_restricted_token_runs_for_legacy_restricted_policies() { - let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); - - assert_eq!( - should_use_windows_restricted_token_sandbox( - SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - ), - true - ); - } - - #[test] - fn windows_restricted_token_rejects_network_only_restrictions() { - let policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, - }; - let file_system_policy = FileSystemSandboxPolicy::unrestricted(); - - assert_eq!( - unsupported_windows_restricted_token_sandbox_reason( - SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - Some( - "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() - ) - ); - } - - #[test] - fn windows_restricted_token_allows_legacy_restricted_policies() { - let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); - - assert_eq!( - unsupported_windows_restricted_token_sandbox_reason( - SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - None - ); - } - - #[test] - fn windows_restricted_token_allows_legacy_workspace_write_policies() { - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; - let file_system_policy = FileSystemSandboxPolicy::from(&policy); - - assert_eq!( - unsupported_windows_restricted_token_sandbox_reason( - SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - None - ); - } - - #[test] - fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() { - let expected = crate::get_platform_sandbox(false).unwrap_or(SandboxType::None); - - assert_eq!( - select_process_exec_tool_sandbox_type( - &FileSystemSandboxPolicy::unrestricted(), - NetworkSandboxPolicy::Restricted, - codex_protocol::config_types::WindowsSandboxLevel::Disabled, - false, - ), - expected - ); - } - - #[cfg(unix)] - #[test] - fn sandbox_detection_flags_sigsys_exit_code() { - let exit_code = EXIT_CODE_SIGNAL_BASE + libc::SIGSYS; - let output = make_exec_output(exit_code, "", "", ""); - assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output)); - } - - #[cfg(unix)] - #[tokio::test] - async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> { - // On Linux/macOS, /bin/bash is typically present; on FreeBSD/OpenBSD, - // prefer /bin/sh to avoid NotFound errors. - #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] - let command = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "sleep 60 & echo $!; sleep 60".to_string(), - ]; - #[cfg(all(unix, not(any(target_os = "freebsd", target_os = "openbsd"))))] - let command = vec![ - "/bin/bash".to_string(), - "-c".to_string(), - "sleep 60 & echo $!; sleep 60".to_string(), - ]; - let env: HashMap = std::env::vars().collect(); - let params = ExecParams { - command, - cwd: std::env::current_dir()?, - expiration: 500.into(), - env, - network: None, - sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, - justification: None, - arg0: None, - }; - - let output = exec( - params, - SandboxType::None, - &SandboxPolicy::new_read_only_policy(), - &FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()), - NetworkSandboxPolicy::Restricted, - None, - None, - ) - .await?; - assert!(output.timed_out); - - let stdout = output.stdout.from_utf8_lossy().text; - let pid_line = stdout.lines().next().unwrap_or("").trim(); - let pid: i32 = pid_line.parse().map_err(|error| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("Failed to parse pid from stdout '{pid_line}': {error}"), - ) - })?; - - let mut killed = false; - for _ in 0..20 { - // Use kill(pid, 0) to check if the process is alive. - if unsafe { libc::kill(pid, 0) } == -1 - && let Some(libc::ESRCH) = std::io::Error::last_os_error().raw_os_error() - { - killed = true; - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - - assert!(killed, "grandchild process with pid {pid} is still alive"); - Ok(()) - } - - #[tokio::test] - async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { - let command = long_running_command(); - let cwd = std::env::current_dir()?; - let env: HashMap = std::env::vars().collect(); - let cancel_token = CancellationToken::new(); - let cancel_tx = cancel_token.clone(); - let params = ExecParams { - command, - cwd: cwd.clone(), - expiration: ExecExpiration::Cancellation(cancel_token), - env, - network: None, - sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, - justification: None, - arg0: None, - }; - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(1_000)).await; - cancel_tx.cancel(); - }); - let result = process_exec_tool_call( - params, - &SandboxPolicy::DangerFullAccess, - &FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), - NetworkSandboxPolicy::Enabled, - cwd.as_path(), - &None, - false, - None, - ) - .await; - let output = match result { - Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => output, - other => panic!("expected timeout error, got {other:?}"), - }; - assert!(output.timed_out); - assert_eq!(output.exit_code, EXEC_TIMEOUT_EXIT_CODE); - Ok(()) - } - - #[cfg(unix)] - fn long_running_command() -> Vec { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "sleep 30".to_string(), - ] - } - - #[cfg(windows)] - fn long_running_command() -> Vec { - vec![ - "powershell.exe".to_string(), - "-NonInteractive".to_string(), - "-NoLogo".to_string(), - "-Command".to_string(), - "Start-Sleep -Seconds 30".to_string(), - ] - } -} +#[path = "exec_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/exec_env.rs b/codex-rs/core/src/exec_env.rs index eabd35b410..83ac8ad379 100644 --- a/codex-rs/core/src/exec_env.rs +++ b/codex-rs/core/src/exec_env.rs @@ -94,220 +94,5 @@ where } #[cfg(test)] -mod tests { - use super::*; - use crate::config::types::ShellEnvironmentPolicyInherit; - use maplit::hashmap; - - fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> { - pairs - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() - } - - #[test] - fn test_core_inherit_defaults_keep_sensitive_vars() { - let vars = make_vars(&[ - ("PATH", "/usr/bin"), - ("HOME", "/home/user"), - ("API_KEY", "secret"), - ("SECRET_TOKEN", "t"), - ]); - - let policy = ShellEnvironmentPolicy::default(); // inherit All, default excludes ignored - let thread_id = ThreadId::new(); - let result = populate_env(vars, &policy, Some(thread_id)); - - let mut expected: HashMap = hashmap! { - "PATH".to_string() => "/usr/bin".to_string(), - "HOME".to_string() => "/home/user".to_string(), - "API_KEY".to_string() => "secret".to_string(), - "SECRET_TOKEN".to_string() => "t".to_string(), - }; - expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); - - assert_eq!(result, expected); - } - - #[test] - fn test_core_inherit_with_default_excludes_enabled() { - let vars = make_vars(&[ - ("PATH", "/usr/bin"), - ("HOME", "/home/user"), - ("API_KEY", "secret"), - ("SECRET_TOKEN", "t"), - ]); - - let policy = ShellEnvironmentPolicy { - ignore_default_excludes: false, // apply KEY/SECRET/TOKEN filter - ..Default::default() - }; - let thread_id = ThreadId::new(); - let result = populate_env(vars, &policy, Some(thread_id)); - - let mut expected: HashMap = hashmap! { - "PATH".to_string() => "/usr/bin".to_string(), - "HOME".to_string() => "/home/user".to_string(), - }; - expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); - - assert_eq!(result, expected); - } - - #[test] - fn test_include_only() { - let vars = make_vars(&[("PATH", "/usr/bin"), ("FOO", "bar")]); - - let policy = ShellEnvironmentPolicy { - // skip default excludes so nothing is removed prematurely - ignore_default_excludes: true, - include_only: vec![EnvironmentVariablePattern::new_case_insensitive("*PATH")], - ..Default::default() - }; - - let thread_id = ThreadId::new(); - let result = populate_env(vars, &policy, Some(thread_id)); - - let mut expected: HashMap = hashmap! { - "PATH".to_string() => "/usr/bin".to_string(), - }; - expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); - - assert_eq!(result, expected); - } - - #[test] - fn test_set_overrides() { - let vars = make_vars(&[("PATH", "/usr/bin")]); - - let mut policy = ShellEnvironmentPolicy { - ignore_default_excludes: true, - ..Default::default() - }; - policy.r#set.insert("NEW_VAR".to_string(), "42".to_string()); - - let thread_id = ThreadId::new(); - let result = populate_env(vars, &policy, Some(thread_id)); - - let mut expected: HashMap = hashmap! { - "PATH".to_string() => "/usr/bin".to_string(), - "NEW_VAR".to_string() => "42".to_string(), - }; - expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); - - assert_eq!(result, expected); - } - - #[test] - fn populate_env_inserts_thread_id() { - let vars = make_vars(&[("PATH", "/usr/bin")]); - let policy = ShellEnvironmentPolicy::default(); - let thread_id = ThreadId::new(); - let result = populate_env(vars, &policy, Some(thread_id)); - - let mut expected: HashMap = hashmap! { - "PATH".to_string() => "/usr/bin".to_string(), - }; - expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); - - assert_eq!(result, expected); - } - - #[test] - fn populate_env_omits_thread_id_when_missing() { - let vars = make_vars(&[("PATH", "/usr/bin")]); - let policy = ShellEnvironmentPolicy::default(); - let result = populate_env(vars, &policy, None); - - let expected: HashMap = hashmap! { - "PATH".to_string() => "/usr/bin".to_string(), - }; - - assert_eq!(result, expected); - } - - #[test] - fn test_inherit_all() { - let vars = make_vars(&[("PATH", "/usr/bin"), ("FOO", "bar")]); - - let policy = ShellEnvironmentPolicy { - inherit: ShellEnvironmentPolicyInherit::All, - ignore_default_excludes: true, // keep everything - ..Default::default() - }; - - let thread_id = ThreadId::new(); - let result = populate_env(vars.clone(), &policy, Some(thread_id)); - let mut expected: HashMap = vars.into_iter().collect(); - expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); - assert_eq!(result, expected); - } - - #[test] - fn test_inherit_all_with_default_excludes() { - let vars = make_vars(&[("PATH", "/usr/bin"), ("API_KEY", "secret")]); - - let policy = ShellEnvironmentPolicy { - inherit: ShellEnvironmentPolicyInherit::All, - ignore_default_excludes: false, - ..Default::default() - }; - - let thread_id = ThreadId::new(); - let result = populate_env(vars, &policy, Some(thread_id)); - let mut expected: HashMap = hashmap! { - "PATH".to_string() => "/usr/bin".to_string(), - }; - expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); - assert_eq!(result, expected); - } - - #[test] - #[cfg(target_os = "windows")] - fn test_core_inherit_respects_case_insensitive_names_on_windows() { - let vars = make_vars(&[ - ("Path", "C:\\Windows\\System32"), - ("TEMP", "C:\\Temp"), - ("FOO", "bar"), - ]); - - let policy = ShellEnvironmentPolicy { - inherit: ShellEnvironmentPolicyInherit::Core, - ignore_default_excludes: true, - ..Default::default() - }; - - let thread_id = ThreadId::new(); - let result = populate_env(vars, &policy, Some(thread_id)); - let mut expected: HashMap = hashmap! { - "Path".to_string() => "C:\\Windows\\System32".to_string(), - "TEMP".to_string() => "C:\\Temp".to_string(), - }; - expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); - - assert_eq!(result, expected); - } - - #[test] - fn test_inherit_none() { - let vars = make_vars(&[("PATH", "/usr/bin"), ("HOME", "/home")]); - - let mut policy = ShellEnvironmentPolicy { - inherit: ShellEnvironmentPolicyInherit::None, - ignore_default_excludes: true, - ..Default::default() - }; - policy - .r#set - .insert("ONLY_VAR".to_string(), "yes".to_string()); - - let thread_id = ThreadId::new(); - let result = populate_env(vars, &policy, Some(thread_id)); - let mut expected: HashMap = hashmap! { - "ONLY_VAR".to_string() => "yes".to_string(), - }; - expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); - assert_eq!(result, expected); - } -} +#[path = "exec_env_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/exec_env_tests.rs b/codex-rs/core/src/exec_env_tests.rs new file mode 100644 index 0000000000..6f001b5828 --- /dev/null +++ b/codex-rs/core/src/exec_env_tests.rs @@ -0,0 +1,215 @@ +use super::*; +use crate::config::types::ShellEnvironmentPolicyInherit; +use maplit::hashmap; + +fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +#[test] +fn test_core_inherit_defaults_keep_sensitive_vars() { + let vars = make_vars(&[ + ("PATH", "/usr/bin"), + ("HOME", "/home/user"), + ("API_KEY", "secret"), + ("SECRET_TOKEN", "t"), + ]); + + let policy = ShellEnvironmentPolicy::default(); // inherit All, default excludes ignored + let thread_id = ThreadId::new(); + let result = populate_env(vars, &policy, Some(thread_id)); + + let mut expected: HashMap = hashmap! { + "PATH".to_string() => "/usr/bin".to_string(), + "HOME".to_string() => "/home/user".to_string(), + "API_KEY".to_string() => "secret".to_string(), + "SECRET_TOKEN".to_string() => "t".to_string(), + }; + expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); + + assert_eq!(result, expected); +} + +#[test] +fn test_core_inherit_with_default_excludes_enabled() { + let vars = make_vars(&[ + ("PATH", "/usr/bin"), + ("HOME", "/home/user"), + ("API_KEY", "secret"), + ("SECRET_TOKEN", "t"), + ]); + + let policy = ShellEnvironmentPolicy { + ignore_default_excludes: false, // apply KEY/SECRET/TOKEN filter + ..Default::default() + }; + let thread_id = ThreadId::new(); + let result = populate_env(vars, &policy, Some(thread_id)); + + let mut expected: HashMap = hashmap! { + "PATH".to_string() => "/usr/bin".to_string(), + "HOME".to_string() => "/home/user".to_string(), + }; + expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); + + assert_eq!(result, expected); +} + +#[test] +fn test_include_only() { + let vars = make_vars(&[("PATH", "/usr/bin"), ("FOO", "bar")]); + + let policy = ShellEnvironmentPolicy { + // skip default excludes so nothing is removed prematurely + ignore_default_excludes: true, + include_only: vec![EnvironmentVariablePattern::new_case_insensitive("*PATH")], + ..Default::default() + }; + + let thread_id = ThreadId::new(); + let result = populate_env(vars, &policy, Some(thread_id)); + + let mut expected: HashMap = hashmap! { + "PATH".to_string() => "/usr/bin".to_string(), + }; + expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); + + assert_eq!(result, expected); +} + +#[test] +fn test_set_overrides() { + let vars = make_vars(&[("PATH", "/usr/bin")]); + + let mut policy = ShellEnvironmentPolicy { + ignore_default_excludes: true, + ..Default::default() + }; + policy.r#set.insert("NEW_VAR".to_string(), "42".to_string()); + + let thread_id = ThreadId::new(); + let result = populate_env(vars, &policy, Some(thread_id)); + + let mut expected: HashMap = hashmap! { + "PATH".to_string() => "/usr/bin".to_string(), + "NEW_VAR".to_string() => "42".to_string(), + }; + expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); + + assert_eq!(result, expected); +} + +#[test] +fn populate_env_inserts_thread_id() { + let vars = make_vars(&[("PATH", "/usr/bin")]); + let policy = ShellEnvironmentPolicy::default(); + let thread_id = ThreadId::new(); + let result = populate_env(vars, &policy, Some(thread_id)); + + let mut expected: HashMap = hashmap! { + "PATH".to_string() => "/usr/bin".to_string(), + }; + expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); + + assert_eq!(result, expected); +} + +#[test] +fn populate_env_omits_thread_id_when_missing() { + let vars = make_vars(&[("PATH", "/usr/bin")]); + let policy = ShellEnvironmentPolicy::default(); + let result = populate_env(vars, &policy, None); + + let expected: HashMap = hashmap! { + "PATH".to_string() => "/usr/bin".to_string(), + }; + + assert_eq!(result, expected); +} + +#[test] +fn test_inherit_all() { + let vars = make_vars(&[("PATH", "/usr/bin"), ("FOO", "bar")]); + + let policy = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::All, + ignore_default_excludes: true, // keep everything + ..Default::default() + }; + + let thread_id = ThreadId::new(); + let result = populate_env(vars.clone(), &policy, Some(thread_id)); + let mut expected: HashMap = vars.into_iter().collect(); + expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); + assert_eq!(result, expected); +} + +#[test] +fn test_inherit_all_with_default_excludes() { + let vars = make_vars(&[("PATH", "/usr/bin"), ("API_KEY", "secret")]); + + let policy = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::All, + ignore_default_excludes: false, + ..Default::default() + }; + + let thread_id = ThreadId::new(); + let result = populate_env(vars, &policy, Some(thread_id)); + let mut expected: HashMap = hashmap! { + "PATH".to_string() => "/usr/bin".to_string(), + }; + expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); + assert_eq!(result, expected); +} + +#[test] +#[cfg(target_os = "windows")] +fn test_core_inherit_respects_case_insensitive_names_on_windows() { + let vars = make_vars(&[ + ("Path", "C:\\Windows\\System32"), + ("TEMP", "C:\\Temp"), + ("FOO", "bar"), + ]); + + let policy = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::Core, + ignore_default_excludes: true, + ..Default::default() + }; + + let thread_id = ThreadId::new(); + let result = populate_env(vars, &policy, Some(thread_id)); + let mut expected: HashMap = hashmap! { + "Path".to_string() => "C:\\Windows\\System32".to_string(), + "TEMP".to_string() => "C:\\Temp".to_string(), + }; + expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); + + assert_eq!(result, expected); +} + +#[test] +fn test_inherit_none() { + let vars = make_vars(&[("PATH", "/usr/bin"), ("HOME", "/home")]); + + let mut policy = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::None, + ignore_default_excludes: true, + ..Default::default() + }; + policy + .r#set + .insert("ONLY_VAR".to_string(), "yes".to_string()); + + let thread_id = ThreadId::new(); + let result = populate_env(vars, &policy, Some(thread_id)); + let mut expected: HashMap = hashmap! { + "ONLY_VAR".to_string() => "yes".to_string(), + }; + expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); + assert_eq!(result, expected); +} diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index fc136fba09..2c9ba28b11 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -21,6 +21,8 @@ use codex_execpolicy::RuleMatch; use codex_execpolicy::blocking_append_allow_prefix_rule; use codex_execpolicy::blocking_append_network_rule; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::permissions::FileSystemSandboxKind; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use thiserror::Error; @@ -173,6 +175,7 @@ pub(crate) struct ExecApprovalRequest<'a> { pub(crate) command: &'a [String], pub(crate) approval_policy: AskForApproval, pub(crate) sandbox_policy: &'a SandboxPolicy, + pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) prefix_rule: Option>, } @@ -204,6 +207,7 @@ impl ExecPolicyManager { command, approval_policy, sandbox_policy, + file_system_sandbox_policy, sandbox_permissions, prefix_rule, } = req; @@ -217,6 +221,7 @@ impl ExecPolicyManager { render_decision_for_unmatched_command( approval_policy, sandbox_policy, + file_system_sandbox_policy, cmd, sandbox_permissions, used_complex_parsing, @@ -488,6 +493,7 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result { - match sandbox_policy { - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + match file_system_sandbox_policy.kind { + FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => { // The user has indicated we should "just run" commands // in their unrestricted environment, so we do so since the // command has not been flagged as dangerous. Decision::Allow } - SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => { - // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for - // non‑escalated, non‑dangerous commands — let the sandbox enforce - // restrictions (e.g., block network/write) without a user prompt. + FileSystemSandboxKind::Restricted => { + // In restricted sandboxes, do not prompt for non-escalated, + // non-dangerous commands; let the sandbox enforce + // restrictions without a user prompt. if sandbox_permissions.requests_sandbox_override() { Decision::Prompt } else { @@ -548,13 +554,13 @@ pub fn render_decision_for_unmatched_command( } } } - AskForApproval::Reject(_) => match sandbox_policy { - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + AskForApproval::Reject(_) => match file_system_sandbox_policy.kind { + FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => { // Mirror on-request behavior for unmatched commands; prompt-vs-reject is handled // by `prompt_is_rejected_by_policy`. Decision::Allow } - SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => { + FileSystemSandboxKind::Restricted => { if sandbox_permissions.requests_sandbox_override() { Decision::Prompt } else { @@ -817,1541 +823,5 @@ async fn collect_policy_files(dir: impl AsRef) -> Result, Exe } #[cfg(test)] -mod tests { - use super::*; - use crate::config_loader::ConfigLayerEntry; - use crate::config_loader::ConfigLayerStack; - use crate::config_loader::ConfigRequirements; - use crate::config_loader::ConfigRequirementsToml; - use codex_app_server_protocol::ConfigLayerSource; - use codex_protocol::protocol::AskForApproval; - use codex_protocol::protocol::RejectConfig; - use codex_protocol::protocol::SandboxPolicy; - use codex_utils_absolute_path::AbsolutePathBuf; - use pretty_assertions::assert_eq; - use std::fs; - use std::path::Path; - use std::path::PathBuf; - use std::sync::Arc; - use tempfile::tempdir; - use toml::Value as TomlValue; - - fn config_stack_for_dot_codex_folder(dot_codex_folder: &Path) -> ConfigLayerStack { - let dot_codex_folder = AbsolutePathBuf::from_absolute_path(dot_codex_folder) - .expect("absolute dot_codex_folder"); - let layer = ConfigLayerEntry::new( - ConfigLayerSource::Project { dot_codex_folder }, - TomlValue::Table(Default::default()), - ); - ConfigLayerStack::new( - vec![layer], - ConfigRequirements::default(), - ConfigRequirementsToml::default(), - ) - .expect("ConfigLayerStack") - } - - fn host_absolute_path(segments: &[&str]) -> String { - let mut path = if cfg!(windows) { - PathBuf::from(r"C:\") - } else { - PathBuf::from("/") - }; - for segment in segments { - path.push(segment); - } - path.to_string_lossy().into_owned() - } - - fn host_program_path(name: &str) -> String { - let executable_name = if cfg!(windows) { - format!("{name}.exe") - } else { - name.to_string() - }; - host_absolute_path(&["usr", "bin", &executable_name]) - } - - fn starlark_string(value: &str) -> String { - value.replace('\\', "\\\\").replace('"', "\\\"") - } - - #[tokio::test] - async fn returns_empty_policy_when_no_policy_files_exist() { - let temp_dir = tempdir().expect("create temp dir"); - let config_stack = config_stack_for_dot_codex_folder(temp_dir.path()); - - let manager = ExecPolicyManager::load(&config_stack) - .await - .expect("manager result"); - let policy = manager.current(); - - let commands = [vec!["rm".to_string()]]; - assert_eq!( - Evaluation { - decision: Decision::Allow, - matched_rules: vec![RuleMatch::HeuristicsRuleMatch { - command: vec!["rm".to_string()], - decision: Decision::Allow - }], - }, - policy.check_multiple(commands.iter(), &|_| Decision::Allow) - ); - assert!(!temp_dir.path().join(RULES_DIR_NAME).exists()); - } - - #[tokio::test] - async fn collect_policy_files_returns_empty_when_dir_missing() { - let temp_dir = tempdir().expect("create temp dir"); - - let policy_dir = temp_dir.path().join(RULES_DIR_NAME); - let files = collect_policy_files(&policy_dir) - .await - .expect("collect policy files"); - - assert!(files.is_empty()); - } - - #[tokio::test] - async fn format_exec_policy_error_with_source_renders_range() { - let temp_dir = tempdir().expect("create temp dir"); - let config_stack = config_stack_for_dot_codex_folder(temp_dir.path()); - let policy_dir = temp_dir.path().join(RULES_DIR_NAME); - fs::create_dir_all(&policy_dir).expect("create policy dir"); - let broken_path = policy_dir.join("broken.rules"); - fs::write( - &broken_path, - r#"prefix_rule( - pattern = ["tmux capture-pane"], - decision = "allow", - match = ["tmux capture-pane -p"], -)"#, - ) - .expect("write broken policy file"); - - let err = load_exec_policy(&config_stack) - .await - .expect_err("expected parse error"); - let rendered = format_exec_policy_error_with_source(&err); - - assert!(rendered.contains("broken.rules:1:")); - assert!(rendered.contains("on or around line 1")); - } - - #[test] - fn parse_starlark_line_from_message_extracts_path_and_line() { - let parsed = parse_starlark_line_from_message( - "/tmp/default.rules:143:1: starlark error: error: Parse error: unexpected new line", - ) - .expect("parse should succeed"); - - assert_eq!(parsed.0, PathBuf::from("/tmp/default.rules")); - assert_eq!(parsed.1, 143); - } - - #[test] - fn parse_starlark_line_from_message_rejects_zero_line() { - let parsed = parse_starlark_line_from_message( - "/tmp/default.rules:0:1: starlark error: error: Parse error: unexpected new line", - ); - assert_eq!(parsed, None); - } - - #[tokio::test] - async fn loads_policies_from_policy_subdirectory() { - let temp_dir = tempdir().expect("create temp dir"); - let config_stack = config_stack_for_dot_codex_folder(temp_dir.path()); - let policy_dir = temp_dir.path().join(RULES_DIR_NAME); - fs::create_dir_all(&policy_dir).expect("create policy dir"); - fs::write( - policy_dir.join("deny.rules"), - r#"prefix_rule(pattern=["rm"], decision="forbidden")"#, - ) - .expect("write policy file"); - - let policy = load_exec_policy(&config_stack) - .await - .expect("policy result"); - let command = [vec!["rm".to_string()]]; - assert_eq!( - Evaluation { - decision: Decision::Forbidden, - matched_rules: vec![RuleMatch::PrefixRuleMatch { - matched_prefix: vec!["rm".to_string()], - decision: Decision::Forbidden, - resolved_program: None, - justification: None, - }], - }, - policy.check_multiple(command.iter(), &|_| Decision::Allow) - ); - } - - #[tokio::test] - async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> { - let temp_dir = tempdir()?; - - let mut requirements_exec_policy = Policy::empty(); - requirements_exec_policy.add_network_rule( - "blocked.example.com", - codex_execpolicy::NetworkRuleProtocol::Https, - Decision::Forbidden, - None, - )?; - - let requirements = ConfigRequirements { - exec_policy: Some(codex_config::Sourced::new( - codex_config::RequirementsExecPolicy::new(requirements_exec_policy), - codex_config::RequirementSource::Unknown, - )), - ..ConfigRequirements::default() - }; - let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?; - let layer = ConfigLayerEntry::new( - ConfigLayerSource::Project { dot_codex_folder }, - TomlValue::Table(Default::default()), - ); - let config_stack = - ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?; - - let policy = load_exec_policy(&config_stack).await?; - let (allowed, denied) = policy.compiled_network_domains(); - - assert!(allowed.is_empty()); - assert_eq!(denied, vec!["blocked.example.com".to_string()]); - Ok(()) - } - - #[tokio::test] - async fn preserves_host_executables_when_requirements_overlay_is_present() -> anyhow::Result<()> - { - let temp_dir = tempdir()?; - let policy_dir = temp_dir.path().join(RULES_DIR_NAME); - fs::create_dir_all(&policy_dir)?; - let git_path = host_absolute_path(&["usr", "bin", "git"]); - let git_path_literal = starlark_string(&git_path); - fs::write( - policy_dir.join("host.rules"), - format!( - r#" -host_executable(name = "git", paths = ["{git_path_literal}"]) -"# - ), - )?; - - let mut requirements_exec_policy = Policy::empty(); - requirements_exec_policy.add_network_rule( - "blocked.example.com", - codex_execpolicy::NetworkRuleProtocol::Https, - Decision::Forbidden, - None, - )?; - - let requirements = ConfigRequirements { - exec_policy: Some(codex_config::Sourced::new( - codex_config::RequirementsExecPolicy::new(requirements_exec_policy), - codex_config::RequirementSource::Unknown, - )), - ..ConfigRequirements::default() - }; - let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?; - let layer = ConfigLayerEntry::new( - ConfigLayerSource::Project { dot_codex_folder }, - TomlValue::Table(Default::default()), - ); - let config_stack = - ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?; - - let policy = load_exec_policy(&config_stack).await?; - - assert_eq!( - policy - .host_executables() - .get("git") - .expect("missing git host executable") - .as_ref(), - [AbsolutePathBuf::try_from(git_path)?] - ); - Ok(()) - } - - #[tokio::test] - async fn ignores_policies_outside_policy_dir() { - let temp_dir = tempdir().expect("create temp dir"); - let config_stack = config_stack_for_dot_codex_folder(temp_dir.path()); - fs::write( - temp_dir.path().join("root.rules"), - r#"prefix_rule(pattern=["ls"], decision="prompt")"#, - ) - .expect("write policy file"); - - let policy = load_exec_policy(&config_stack) - .await - .expect("policy result"); - let command = [vec!["ls".to_string()]]; - assert_eq!( - Evaluation { - decision: Decision::Allow, - matched_rules: vec![RuleMatch::HeuristicsRuleMatch { - command: vec!["ls".to_string()], - decision: Decision::Allow - }], - }, - policy.check_multiple(command.iter(), &|_| Decision::Allow) - ); - } - - #[tokio::test] - async fn ignores_rules_from_untrusted_project_layers() -> anyhow::Result<()> { - let project_dir = tempdir()?; - let policy_dir = project_dir.path().join(RULES_DIR_NAME); - fs::create_dir_all(&policy_dir)?; - fs::write( - policy_dir.join("untrusted.rules"), - r#"prefix_rule(pattern=["ls"], decision="forbidden")"#, - )?; - - let project_dot_codex_folder = AbsolutePathBuf::from_absolute_path(project_dir.path())?; - let layers = vec![ConfigLayerEntry::new_disabled( - ConfigLayerSource::Project { - dot_codex_folder: project_dot_codex_folder, - }, - TomlValue::Table(Default::default()), - "marked untrusted", - )]; - let config_stack = ConfigLayerStack::new( - layers, - ConfigRequirements::default(), - ConfigRequirementsToml::default(), - )?; - - let policy = load_exec_policy(&config_stack).await?; - - assert_eq!( - Evaluation { - decision: Decision::Allow, - matched_rules: vec![RuleMatch::HeuristicsRuleMatch { - command: vec!["ls".to_string()], - decision: Decision::Allow, - }], - }, - policy.check_multiple([vec!["ls".to_string()]].iter(), &|_| Decision::Allow) - ); - Ok(()) - } - - #[tokio::test] - async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> { - let user_dir = tempdir()?; - let project_dir = tempdir()?; - - let user_policy_dir = user_dir.path().join(RULES_DIR_NAME); - fs::create_dir_all(&user_policy_dir)?; - fs::write( - user_policy_dir.join("user.rules"), - r#"prefix_rule(pattern=["rm"], decision="forbidden")"#, - )?; - - let project_policy_dir = project_dir.path().join(RULES_DIR_NAME); - fs::create_dir_all(&project_policy_dir)?; - fs::write( - project_policy_dir.join("project.rules"), - r#"prefix_rule(pattern=["ls"], decision="prompt")"#, - )?; - - let user_config_toml = - AbsolutePathBuf::from_absolute_path(user_dir.path().join("config.toml"))?; - let project_dot_codex_folder = AbsolutePathBuf::from_absolute_path(project_dir.path())?; - let layers = vec![ - ConfigLayerEntry::new( - ConfigLayerSource::User { - file: user_config_toml, - }, - TomlValue::Table(Default::default()), - ), - ConfigLayerEntry::new( - ConfigLayerSource::Project { - dot_codex_folder: project_dot_codex_folder, - }, - TomlValue::Table(Default::default()), - ), - ]; - let config_stack = ConfigLayerStack::new( - layers, - ConfigRequirements::default(), - ConfigRequirementsToml::default(), - )?; - - let policy = load_exec_policy(&config_stack).await?; - - assert_eq!( - Evaluation { - decision: Decision::Forbidden, - matched_rules: vec![RuleMatch::PrefixRuleMatch { - matched_prefix: vec!["rm".to_string()], - decision: Decision::Forbidden, - resolved_program: None, - justification: None, - }], - }, - policy.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow) - ); - assert_eq!( - Evaluation { - decision: Decision::Prompt, - matched_rules: vec![RuleMatch::PrefixRuleMatch { - matched_prefix: vec!["ls".to_string()], - decision: Decision::Prompt, - resolved_program: None, - justification: None, - }], - }, - policy.check_multiple([vec!["ls".to_string()]].iter(), &|_| Decision::Allow) - ); - Ok(()) - } - - #[tokio::test] - async fn evaluates_bash_lc_inner_commands() { - let policy_src = r#" -prefix_rule(pattern=["rm"], decision="forbidden") -"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - - let forbidden_script = vec![ - "bash".to_string(), - "-lc".to_string(), - "rm -rf /some/important/folder".to_string(), - ]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &forbidden_script, - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Forbidden { - reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string() - } - ); - } - - #[test] - fn commands_for_exec_policy_falls_back_for_empty_shell_script() { - let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()]; - - assert_eq!(commands_for_exec_policy(&command), (vec![command], false)); - } - - #[test] - fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - " \n\t ".to_string(), - ]; - - assert_eq!(commands_for_exec_policy(&command), (vec![command], false)); - } - - #[tokio::test] - async fn evaluates_heredoc_script_against_prefix_rules() { - let policy_src = r#"prefix_rule(pattern=["python3"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "python3 <<'PY'\nprint('hello')\nPY".to_string(), - ]; - - let requirement = ExecPolicyManager::new(policy) - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Skip { - bypass_sandbox: true, - proposed_execpolicy_amendment: None, - } - ); - } - - #[tokio::test] - async fn omits_auto_amendment_for_heredoc_fallback_prompts() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "python3 <<'PY'\nprint('hello')\nPY".to_string(), - ]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: None, - } - ); - } - - #[tokio::test] - async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_match() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "python3 <<'PY'\nprint('hello')\nPY".to_string(), - ]; - let requested_prefix = vec!["python3".to_string(), "-m".to_string(), "pip".to_string()]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: Some(requested_prefix.clone()), - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: None, - } - ); - } - - #[tokio::test] - async fn justification_is_included_in_forbidden_exec_approval_requirement() { - let policy_src = r#" -prefix_rule( - pattern=["rm"], - decision="forbidden", - justification="destructive command", -) -"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &[ - "rm".to_string(), - "-rf".to_string(), - "/some/important/folder".to_string(), - ], - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Forbidden { - reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string() - } - ); - } - - #[tokio::test] - async fn exec_approval_requirement_prefers_execpolicy_match() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: Some("`rm` requires approval by policy".to_string()), - proposed_execpolicy_amendment: None, - } - ); - } - - #[tokio::test] - async fn absolute_path_exec_approval_requirement_matches_host_executable_rules() { - let git_path = host_program_path("git"); - let git_path_literal = starlark_string(&git_path); - let policy_src = format!( - r#" -host_executable(name = "git", paths = ["{git_path_literal}"]) -prefix_rule(pattern=["git"], decision="allow") -"# - ); - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", &policy_src) - .expect("parse policy"); - let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![git_path, "status".to_string()]; - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Skip { - bypass_sandbox: true, - proposed_execpolicy_amendment: None, - } - ); - } - - #[tokio::test] - async fn absolute_path_exec_approval_requirement_ignores_disallowed_host_executable_paths() { - let allowed_git_path = host_program_path("git"); - let disallowed_git_path = host_absolute_path(&[ - "opt", - "homebrew", - "bin", - if cfg!(windows) { "git.exe" } else { "git" }, - ]); - let allowed_git_path_literal = starlark_string(&allowed_git_path); - let policy_src = format!( - r#" -host_executable(name = "git", paths = ["{allowed_git_path_literal}"]) -prefix_rule(pattern=["git"], decision="prompt") -"# - ); - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", &policy_src) - .expect("parse policy"); - let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![disallowed_git_path, "status".to_string()]; - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Skip { - bypass_sandbox: false, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); - } - - #[tokio::test] - async fn requested_prefix_rule_can_approve_absolute_path_commands() { - let command = vec![ - host_program_path("cargo"), - "install".to_string(), - "cargo-insta".to_string(), - ]; - let manager = ExecPolicyManager::default(); - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "cargo".to_string(), - "install".to_string(), - ])), - } - ); - } - - #[tokio::test] - async fn exec_approval_requirement_respects_approval_policy() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::Never, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Forbidden { - reason: PROMPT_CONFLICT_REASON.to_string() - } - ); - } - - #[test] - fn unmatched_reject_policy_still_prompts_for_restricted_sandbox_escalation() { - let command = vec!["madeup-cmd".to_string()]; - - assert_eq!( - Decision::Prompt, - render_decision_for_unmatched_command( - AskForApproval::Reject(RejectConfig { - sandbox_approval: false, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - }), - &SandboxPolicy::new_read_only_policy(), - &command, - SandboxPermissions::RequireEscalated, - false, - ) - ); - } - - #[tokio::test] - async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_sandbox_rejection_enabled() - { - let command = vec!["madeup-cmd".to_string()]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::Reject(RejectConfig { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - }), - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::RequireEscalated, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Forbidden { - reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(), - } - ); - } - - #[tokio::test] - async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() { - let policy_src = r#"prefix_rule(pattern=["git"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "git status && madeup-cmd".to_string(), - ]; - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::Reject(RejectConfig { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - }), - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::RequireEscalated, - prefix_rule: None, - }) - .await; - - assert!(matches!( - requirement, - ExecApprovalRequirement::NeedsApproval { .. } - )); - } - - #[tokio::test] - async fn mixed_rule_and_sandbox_prompt_rejects_when_rules_rejection_enabled() { - let policy_src = r#"prefix_rule(pattern=["git"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "git status && madeup-cmd".to_string(), - ]; - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::Reject(RejectConfig { - sandbox_approval: false, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - }), - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::RequireEscalated, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Forbidden { - reason: REJECT_RULES_APPROVAL_REASON.to_string(), - } - ); - } - - #[tokio::test] - async fn exec_approval_requirement_falls_back_to_heuristics() { - let command = vec!["cargo".to_string(), "build".to_string()]; - - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)) - } - ); - } - - #[tokio::test] - async fn empty_bash_lc_script_falls_back_to_original_command() { - let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()]; - - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); - } - - #[tokio::test] - async fn whitespace_bash_lc_script_falls_back_to_original_command() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - " \n\t ".to_string(), - ]; - - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); - } - - #[tokio::test] - async fn request_rule_uses_prefix_rule() { - let command = vec![ - "cargo".to_string(), - "install".to_string(), - "cargo-insta".to_string(), - ]; - let manager = ExecPolicyManager::default(); - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::RequireEscalated, - prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "cargo".to_string(), - "install".to_string(), - ])), - } - ); - } - - #[tokio::test] - async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "cargo install cargo-insta && rm -rf /tmp/codex".to_string(), - ]; - let manager = ExecPolicyManager::default(); - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - sandbox_permissions: SandboxPermissions::RequireEscalated, - prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "rm".to_string(), - "-rf".to_string(), - "/tmp/codex".to_string(), - ])), - } - ); - } - - #[tokio::test] - async fn heuristics_apply_when_other_commands_match_policy() { - let policy_src = r#"prefix_rule(pattern=["apple"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "apple | orange".to_string(), - ]; - - assert_eq!( - ExecPolicyManager::new(policy) - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "orange".to_string() - ])) - } - ); - } - - #[tokio::test] - async fn append_execpolicy_amendment_updates_policy_and_file() { - let codex_home = tempdir().expect("create temp dir"); - let prefix = vec!["echo".to_string(), "hello".to_string()]; - let manager = ExecPolicyManager::default(); - - manager - .append_amendment_and_update(codex_home.path(), &ExecPolicyAmendment::from(prefix)) - .await - .expect("update policy"); - let updated_policy = manager.current(); - - let evaluation = updated_policy.check( - &["echo".to_string(), "hello".to_string(), "world".to_string()], - &|_| Decision::Allow, - ); - assert!(matches!( - evaluation, - Evaluation { - decision: Decision::Allow, - .. - } - )); - - let contents = fs::read_to_string(default_policy_path(codex_home.path())) - .expect("policy file should have been created"); - assert_eq!( - contents, - r#"prefix_rule(pattern=["echo", "hello"], decision="allow") -"# - ); - } - - #[tokio::test] - async fn append_execpolicy_amendment_rejects_empty_prefix() { - let codex_home = tempdir().expect("create temp dir"); - let manager = ExecPolicyManager::default(); - - let result = manager - .append_amendment_and_update(codex_home.path(), &ExecPolicyAmendment::from(vec![])) - .await; - - assert!(matches!( - result, - Err(ExecPolicyUpdateError::AppendRule { - source: AmendError::EmptyPrefix, - .. - }) - )); - } - - #[tokio::test] - async fn proposed_execpolicy_amendment_is_present_for_single_command_without_policy_match() { - let command = vec!["cargo".to_string(), "build".to_string()]; - - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)) - } - ); - } - - #[tokio::test] - async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: Some("`rm` requires approval by policy".to_string()), - proposed_execpolicy_amendment: None, - } - ); - } - - #[tokio::test] - async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "cargo build && echo ok".to_string(), - ]; - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "cargo".to_string(), - "build".to_string() - ])), - } - ); - } - - #[tokio::test] - async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scripts() { - let policy_src = r#"prefix_rule(pattern=["cat"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "cat && apple".to_string(), - ]; - - assert_eq!( - ExecPolicyManager::new(policy) - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "apple".to_string() - ])), - } - ); - } - - #[tokio::test] - async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { - let command = vec!["echo".to_string(), "safe".to_string()]; - - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Skip { - bypass_sandbox: false, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); - } - - #[tokio::test] - async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() { - let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["echo".to_string(), "safe".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Skip { - bypass_sandbox: true, - proposed_execpolicy_amendment: None, - } - ); - } - - fn derive_requested_execpolicy_amendment_for_test( - prefix_rule: Option<&Vec>, - matched_rules: &[RuleMatch], - ) -> Option { - let commands = prefix_rule - .cloned() - .map(|prefix_rule| vec![prefix_rule]) - .unwrap_or_else(|| vec![vec!["echo".to_string()]]); - derive_requested_execpolicy_amendment_from_prefix_rule( - prefix_rule, - matched_rules, - &Policy::empty(), - &commands, - &|_: &[String]| Decision::Allow, - &MatchOptions::default(), - ) - } - - #[test] - fn derive_requested_execpolicy_amendment_returns_none_for_missing_prefix_rule() { - assert_eq!( - None, - derive_requested_execpolicy_amendment_for_test(None, &[]) - ); - } - - #[test] - fn derive_requested_execpolicy_amendment_returns_none_for_empty_prefix_rule() { - assert_eq!( - None, - derive_requested_execpolicy_amendment_for_test(Some(&Vec::new()), &[]) - ); - } - - #[test] - fn derive_requested_execpolicy_amendment_returns_none_for_exact_banned_prefix_rule() { - assert_eq!( - None, - derive_requested_execpolicy_amendment_for_test( - Some(&vec!["python".to_string(), "-c".to_string()]), - &[], - ) - ); - } - - #[test] - fn derive_requested_execpolicy_amendment_returns_none_for_windows_and_pypy_variants() { - for prefix_rule in [ - vec!["py".to_string()], - vec!["py".to_string(), "-3".to_string()], - vec!["pythonw".to_string()], - vec!["pyw".to_string()], - vec!["pypy".to_string()], - vec!["pypy3".to_string()], - ] { - assert_eq!( - None, - derive_requested_execpolicy_amendment_for_test(Some(&prefix_rule), &[]) - ); - } - } - - #[test] - fn derive_requested_execpolicy_amendment_returns_none_for_shell_and_powershell_variants() { - for prefix_rule in [ - vec!["bash".to_string(), "-lc".to_string()], - vec!["sh".to_string(), "-c".to_string()], - vec!["sh".to_string(), "-lc".to_string()], - vec!["zsh".to_string(), "-lc".to_string()], - vec!["/bin/bash".to_string(), "-lc".to_string()], - vec!["/bin/zsh".to_string(), "-lc".to_string()], - vec!["pwsh".to_string()], - vec!["pwsh".to_string(), "-Command".to_string()], - vec!["pwsh".to_string(), "-c".to_string()], - vec!["powershell".to_string()], - vec!["powershell".to_string(), "-Command".to_string()], - vec!["powershell".to_string(), "-c".to_string()], - vec!["powershell.exe".to_string()], - vec!["powershell.exe".to_string(), "-Command".to_string()], - vec!["powershell.exe".to_string(), "-c".to_string()], - ] { - assert_eq!( - None, - derive_requested_execpolicy_amendment_for_test(Some(&prefix_rule), &[]) - ); - } - } - - #[test] - fn derive_requested_execpolicy_amendment_allows_non_exact_banned_prefix_rule_match() { - let prefix_rule = vec![ - "python".to_string(), - "-c".to_string(), - "print('hi')".to_string(), - ]; - - assert_eq!( - Some(ExecPolicyAmendment::new(prefix_rule.clone())), - derive_requested_execpolicy_amendment_for_test(Some(&prefix_rule), &[]) - ); - } - - #[test] - fn derive_requested_execpolicy_amendment_returns_none_when_policy_matches() { - let prefix_rule = vec!["cargo".to_string(), "build".to_string()]; - - let matched_rules_prompt = vec![RuleMatch::PrefixRuleMatch { - matched_prefix: vec!["cargo".to_string()], - decision: Decision::Prompt, - resolved_program: None, - justification: None, - }]; - assert_eq!( - None, - derive_requested_execpolicy_amendment_for_test( - Some(&prefix_rule), - &matched_rules_prompt - ), - "should return none when prompt policy matches" - ); - let matched_rules_allow = vec![RuleMatch::PrefixRuleMatch { - matched_prefix: vec!["cargo".to_string()], - decision: Decision::Allow, - resolved_program: None, - justification: None, - }]; - assert_eq!( - None, - derive_requested_execpolicy_amendment_for_test( - Some(&prefix_rule), - &matched_rules_allow - ), - "should return none when prompt policy matches" - ); - let matched_rules_forbidden = vec![RuleMatch::PrefixRuleMatch { - matched_prefix: vec!["cargo".to_string()], - decision: Decision::Forbidden, - resolved_program: None, - justification: None, - }]; - assert_eq!( - None, - derive_requested_execpolicy_amendment_for_test( - Some(&prefix_rule), - &matched_rules_forbidden, - ), - "should return none when prompt policy matches" - ); - } - - #[tokio::test] - async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { - let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]); - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); - } - - fn vec_str(items: &[&str]) -> Vec { - items.iter().map(std::string::ToString::to_string).collect() - } - - /// Note this test behaves differently on Windows because it exercises an - /// `if cfg!(windows)` code path in render_decision_for_unmatched_command(). - #[tokio::test] - async fn verify_approval_requirement_for_unsafe_powershell_command() { - // `brew install powershell` to run this test on a Mac! - // Note `pwsh` is required to parse a PowerShell command to see if it - // is safe. - if which::which("pwsh").is_err() { - return; - } - - let policy = ExecPolicyManager::new(Arc::new(Policy::empty())); - let permissions = SandboxPermissions::UseDefault; - - // This command should not be run without user approval unless there is - // a proper sandbox in place to ensure safety. - let sneaky_command = vec_str(&["pwsh", "-Command", "echo hi @(calc)"]); - let expected_amendment = Some(ExecPolicyAmendment::new(vec_str(&[ - "pwsh", - "-Command", - "echo hi @(calc)", - ]))); - let (pwsh_approval_reason, expected_req) = if cfg!(windows) { - ( - r#"On Windows, SandboxPolicy::ReadOnly should be assumed to mean - that no sandbox is present, so anything that is not "provably - safe" should require approval."#, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: expected_amendment.clone(), - }, - ) - } else { - ( - "On non-Windows, rely on the read-only sandbox to prevent harm.", - ExecApprovalRequirement::Skip { - bypass_sandbox: false, - proposed_execpolicy_amendment: expected_amendment.clone(), - }, - ) - }; - assert_eq!( - expected_req, - policy - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &sneaky_command, - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: permissions, - prefix_rule: None, - }) - .await, - "{pwsh_approval_reason}" - ); - - // This is flagged as a dangerous command on all platforms. - let dangerous_command = vec_str(&["rm", "-rf", "/important/data"]); - assert_eq!( - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec_str(&[ - "rm", - "-rf", - "/important/data", - ]))), - }, - policy - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &dangerous_command, - approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: permissions, - prefix_rule: None, - }) - .await, - r#"On all platforms, a forbidden command should require approval - (unless AskForApproval::Never is specified)."# - ); - - // A dangerous command should be forbidden if the user has specified - // AskForApproval::Never. - assert_eq!( - ExecApprovalRequirement::Forbidden { - reason: "`rm -rf /important/data` rejected: blocked by policy".to_string(), - }, - policy - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &dangerous_command, - approval_policy: AskForApproval::Never, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - sandbox_permissions: permissions, - prefix_rule: None, - }) - .await, - r#"On all platforms, a forbidden command should require approval - (unless AskForApproval::Never is specified)."# - ); - } -} +#[path = "exec_policy_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs new file mode 100644 index 0000000000..8c7286635e --- /dev/null +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -0,0 +1,1594 @@ +use super::*; +use crate::config_loader::ConfigLayerEntry; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigRequirements; +use crate::config_loader::ConfigRequirementsToml; +use codex_app_server_protocol::ConfigLayerSource; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::RejectConfig; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::tempdir; +use toml::Value as TomlValue; + +fn config_stack_for_dot_codex_folder(dot_codex_folder: &Path) -> ConfigLayerStack { + let dot_codex_folder = + AbsolutePathBuf::from_absolute_path(dot_codex_folder).expect("absolute dot_codex_folder"); + let layer = ConfigLayerEntry::new( + ConfigLayerSource::Project { dot_codex_folder }, + TomlValue::Table(Default::default()), + ); + ConfigLayerStack::new( + vec![layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("ConfigLayerStack") +} + +fn host_absolute_path(segments: &[&str]) -> String { + let mut path = if cfg!(windows) { + PathBuf::from(r"C:\") + } else { + PathBuf::from("/") + }; + for segment in segments { + path.push(segment); + } + path.to_string_lossy().into_owned() +} + +fn host_program_path(name: &str) -> String { + let executable_name = if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_string() + }; + host_absolute_path(&["usr", "bin", &executable_name]) +} + +fn starlark_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }]) +} + +fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { + FileSystemSandboxPolicy::unrestricted() +} + +#[tokio::test] +async fn returns_empty_policy_when_no_policy_files_exist() { + let temp_dir = tempdir().expect("create temp dir"); + let config_stack = config_stack_for_dot_codex_folder(temp_dir.path()); + + let manager = ExecPolicyManager::load(&config_stack) + .await + .expect("manager result"); + let policy = manager.current(); + + let commands = [vec!["rm".to_string()]]; + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: vec!["rm".to_string()], + decision: Decision::Allow + }], + }, + policy.check_multiple(commands.iter(), &|_| Decision::Allow) + ); + assert!(!temp_dir.path().join(RULES_DIR_NAME).exists()); +} + +#[tokio::test] +async fn collect_policy_files_returns_empty_when_dir_missing() { + let temp_dir = tempdir().expect("create temp dir"); + + let policy_dir = temp_dir.path().join(RULES_DIR_NAME); + let files = collect_policy_files(&policy_dir) + .await + .expect("collect policy files"); + + assert!(files.is_empty()); +} + +#[tokio::test] +async fn format_exec_policy_error_with_source_renders_range() { + let temp_dir = tempdir().expect("create temp dir"); + let config_stack = config_stack_for_dot_codex_folder(temp_dir.path()); + let policy_dir = temp_dir.path().join(RULES_DIR_NAME); + fs::create_dir_all(&policy_dir).expect("create policy dir"); + let broken_path = policy_dir.join("broken.rules"); + fs::write( + &broken_path, + r#"prefix_rule( + pattern = ["tmux capture-pane"], + decision = "allow", + match = ["tmux capture-pane -p"], +)"#, + ) + .expect("write broken policy file"); + + let err = load_exec_policy(&config_stack) + .await + .expect_err("expected parse error"); + let rendered = format_exec_policy_error_with_source(&err); + + assert!(rendered.contains("broken.rules:1:")); + assert!(rendered.contains("on or around line 1")); +} + +#[test] +fn parse_starlark_line_from_message_extracts_path_and_line() { + let parsed = parse_starlark_line_from_message( + "/tmp/default.rules:143:1: starlark error: error: Parse error: unexpected new line", + ) + .expect("parse should succeed"); + + assert_eq!(parsed.0, PathBuf::from("/tmp/default.rules")); + assert_eq!(parsed.1, 143); +} + +#[test] +fn parse_starlark_line_from_message_rejects_zero_line() { + let parsed = parse_starlark_line_from_message( + "/tmp/default.rules:0:1: starlark error: error: Parse error: unexpected new line", + ); + assert_eq!(parsed, None); +} + +#[tokio::test] +async fn loads_policies_from_policy_subdirectory() { + let temp_dir = tempdir().expect("create temp dir"); + let config_stack = config_stack_for_dot_codex_folder(temp_dir.path()); + let policy_dir = temp_dir.path().join(RULES_DIR_NAME); + fs::create_dir_all(&policy_dir).expect("create policy dir"); + fs::write( + policy_dir.join("deny.rules"), + r#"prefix_rule(pattern=["rm"], decision="forbidden")"#, + ) + .expect("write policy file"); + + let policy = load_exec_policy(&config_stack) + .await + .expect("policy result"); + let command = [vec!["rm".to_string()]]; + assert_eq!( + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["rm".to_string()], + decision: Decision::Forbidden, + resolved_program: None, + justification: None, + }], + }, + policy.check_multiple(command.iter(), &|_| Decision::Allow) + ); +} + +#[tokio::test] +async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> { + let temp_dir = tempdir()?; + + let mut requirements_exec_policy = Policy::empty(); + requirements_exec_policy.add_network_rule( + "blocked.example.com", + codex_execpolicy::NetworkRuleProtocol::Https, + Decision::Forbidden, + None, + )?; + + let requirements = ConfigRequirements { + exec_policy: Some(codex_config::Sourced::new( + codex_config::RequirementsExecPolicy::new(requirements_exec_policy), + codex_config::RequirementSource::Unknown, + )), + ..ConfigRequirements::default() + }; + let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?; + let layer = ConfigLayerEntry::new( + ConfigLayerSource::Project { dot_codex_folder }, + TomlValue::Table(Default::default()), + ); + let config_stack = + ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?; + + let policy = load_exec_policy(&config_stack).await?; + let (allowed, denied) = policy.compiled_network_domains(); + + assert!(allowed.is_empty()); + assert_eq!(denied, vec!["blocked.example.com".to_string()]); + Ok(()) +} + +#[tokio::test] +async fn preserves_host_executables_when_requirements_overlay_is_present() -> anyhow::Result<()> { + let temp_dir = tempdir()?; + let policy_dir = temp_dir.path().join(RULES_DIR_NAME); + fs::create_dir_all(&policy_dir)?; + let git_path = host_absolute_path(&["usr", "bin", "git"]); + let git_path_literal = starlark_string(&git_path); + fs::write( + policy_dir.join("host.rules"), + format!( + r#" +host_executable(name = "git", paths = ["{git_path_literal}"]) +"# + ), + )?; + + let mut requirements_exec_policy = Policy::empty(); + requirements_exec_policy.add_network_rule( + "blocked.example.com", + codex_execpolicy::NetworkRuleProtocol::Https, + Decision::Forbidden, + None, + )?; + + let requirements = ConfigRequirements { + exec_policy: Some(codex_config::Sourced::new( + codex_config::RequirementsExecPolicy::new(requirements_exec_policy), + codex_config::RequirementSource::Unknown, + )), + ..ConfigRequirements::default() + }; + let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?; + let layer = ConfigLayerEntry::new( + ConfigLayerSource::Project { dot_codex_folder }, + TomlValue::Table(Default::default()), + ); + let config_stack = + ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?; + + let policy = load_exec_policy(&config_stack).await?; + + assert_eq!( + policy + .host_executables() + .get("git") + .expect("missing git host executable") + .as_ref(), + [AbsolutePathBuf::try_from(git_path)?] + ); + Ok(()) +} + +#[tokio::test] +async fn ignores_policies_outside_policy_dir() { + let temp_dir = tempdir().expect("create temp dir"); + let config_stack = config_stack_for_dot_codex_folder(temp_dir.path()); + fs::write( + temp_dir.path().join("root.rules"), + r#"prefix_rule(pattern=["ls"], decision="prompt")"#, + ) + .expect("write policy file"); + + let policy = load_exec_policy(&config_stack) + .await + .expect("policy result"); + let command = [vec!["ls".to_string()]]; + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: vec!["ls".to_string()], + decision: Decision::Allow + }], + }, + policy.check_multiple(command.iter(), &|_| Decision::Allow) + ); +} + +#[tokio::test] +async fn ignores_rules_from_untrusted_project_layers() -> anyhow::Result<()> { + let project_dir = tempdir()?; + let policy_dir = project_dir.path().join(RULES_DIR_NAME); + fs::create_dir_all(&policy_dir)?; + fs::write( + policy_dir.join("untrusted.rules"), + r#"prefix_rule(pattern=["ls"], decision="forbidden")"#, + )?; + + let project_dot_codex_folder = AbsolutePathBuf::from_absolute_path(project_dir.path())?; + let layers = vec![ConfigLayerEntry::new_disabled( + ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex_folder, + }, + TomlValue::Table(Default::default()), + "marked untrusted", + )]; + let config_stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let policy = load_exec_policy(&config_stack).await?; + + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: vec!["ls".to_string()], + decision: Decision::Allow, + }], + }, + policy.check_multiple([vec!["ls".to_string()]].iter(), &|_| Decision::Allow) + ); + Ok(()) +} + +#[tokio::test] +async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> { + let user_dir = tempdir()?; + let project_dir = tempdir()?; + + let user_policy_dir = user_dir.path().join(RULES_DIR_NAME); + fs::create_dir_all(&user_policy_dir)?; + fs::write( + user_policy_dir.join("user.rules"), + r#"prefix_rule(pattern=["rm"], decision="forbidden")"#, + )?; + + let project_policy_dir = project_dir.path().join(RULES_DIR_NAME); + fs::create_dir_all(&project_policy_dir)?; + fs::write( + project_policy_dir.join("project.rules"), + r#"prefix_rule(pattern=["ls"], decision="prompt")"#, + )?; + + let user_config_toml = + AbsolutePathBuf::from_absolute_path(user_dir.path().join("config.toml"))?; + let project_dot_codex_folder = AbsolutePathBuf::from_absolute_path(project_dir.path())?; + let layers = vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_config_toml, + }, + TomlValue::Table(Default::default()), + ), + ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex_folder, + }, + TomlValue::Table(Default::default()), + ), + ]; + let config_stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let policy = load_exec_policy(&config_stack).await?; + + assert_eq!( + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["rm".to_string()], + decision: Decision::Forbidden, + resolved_program: None, + justification: None, + }], + }, + policy.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow) + ); + assert_eq!( + Evaluation { + decision: Decision::Prompt, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["ls".to_string()], + decision: Decision::Prompt, + resolved_program: None, + justification: None, + }], + }, + policy.check_multiple([vec!["ls".to_string()]].iter(), &|_| Decision::Allow) + ); + Ok(()) +} + +#[tokio::test] +async fn evaluates_bash_lc_inner_commands() { + let policy_src = r#" +prefix_rule(pattern=["rm"], decision="forbidden") +"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let policy = Arc::new(parser.build()); + + let forbidden_script = vec![ + "bash".to_string(), + "-lc".to_string(), + "rm -rf /some/important/folder".to_string(), + ]; + + let manager = ExecPolicyManager::new(policy); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &forbidden_script, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Forbidden { + reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string() + } + ); +} + +#[test] +fn commands_for_exec_policy_falls_back_for_empty_shell_script() { + let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()]; + + assert_eq!(commands_for_exec_policy(&command), (vec![command], false)); +} + +#[test] +fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + " \n\t ".to_string(), + ]; + + assert_eq!(commands_for_exec_policy(&command), (vec![command], false)); +} + +#[tokio::test] +async fn evaluates_heredoc_script_against_prefix_rules() { + let policy_src = r#"prefix_rule(pattern=["python3"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let policy = Arc::new(parser.build()); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ]; + + let requirement = ExecPolicyManager::new(policy) + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + } + ); +} + +#[tokio::test] +async fn omits_auto_amendment_for_heredoc_fallback_prompts() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ]; + + let requirement = ExecPolicyManager::default() + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + } + ); +} + +#[tokio::test] +async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_match() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ]; + let requested_prefix = vec!["python3".to_string(), "-m".to_string(), "pip".to_string()]; + + let requirement = ExecPolicyManager::default() + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: Some(requested_prefix.clone()), + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + } + ); +} + +#[tokio::test] +async fn justification_is_included_in_forbidden_exec_approval_requirement() { + let policy_src = r#" +prefix_rule( + pattern=["rm"], + decision="forbidden", + justification="destructive command", +) +"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let policy = Arc::new(parser.build()); + + let manager = ExecPolicyManager::new(policy); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &[ + "rm".to_string(), + "-rf".to_string(), + "/some/important/folder".to_string(), + ], + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Forbidden { + reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string() + } + ); +} + +#[tokio::test] +async fn exec_approval_requirement_prefers_execpolicy_match() { + let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let policy = Arc::new(parser.build()); + let command = vec!["rm".to_string()]; + + let manager = ExecPolicyManager::new(policy); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: Some("`rm` requires approval by policy".to_string()), + proposed_execpolicy_amendment: None, + } + ); +} + +#[tokio::test] +async fn absolute_path_exec_approval_requirement_matches_host_executable_rules() { + let git_path = host_program_path("git"); + let git_path_literal = starlark_string(&git_path); + let policy_src = format!( + r#" +host_executable(name = "git", paths = ["{git_path_literal}"]) +prefix_rule(pattern=["git"], decision="allow") +"# + ); + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", &policy_src) + .expect("parse policy"); + let manager = ExecPolicyManager::new(Arc::new(parser.build())); + let command = vec![git_path, "status".to_string()]; + + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + } + ); +} + +#[tokio::test] +async fn absolute_path_exec_approval_requirement_ignores_disallowed_host_executable_paths() { + let allowed_git_path = host_program_path("git"); + let disallowed_git_path = host_absolute_path(&[ + "opt", + "homebrew", + "bin", + if cfg!(windows) { "git.exe" } else { "git" }, + ]); + let allowed_git_path_literal = starlark_string(&allowed_git_path); + let policy_src = format!( + r#" +host_executable(name = "git", paths = ["{allowed_git_path_literal}"]) +prefix_rule(pattern=["git"], decision="prompt") +"# + ); + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", &policy_src) + .expect("parse policy"); + let manager = ExecPolicyManager::new(Arc::new(parser.build())); + let command = vec![disallowed_git_path, "status".to_string()]; + + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + } + ); +} + +#[tokio::test] +async fn requested_prefix_rule_can_approve_absolute_path_commands() { + let command = vec![ + host_program_path("cargo"), + "install".to_string(), + "cargo-insta".to_string(), + ]; + let manager = ExecPolicyManager::default(); + + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "cargo".to_string(), + "install".to_string(), + ])), + } + ); +} + +#[tokio::test] +async fn exec_approval_requirement_respects_approval_policy() { + let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let policy = Arc::new(parser.build()); + let command = vec!["rm".to_string()]; + + let manager = ExecPolicyManager::new(policy); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::Never, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Forbidden { + reason: PROMPT_CONFLICT_REASON.to_string() + } + ); +} + +#[test] +fn unmatched_reject_policy_still_prompts_for_restricted_sandbox_escalation() { + let command = vec!["madeup-cmd".to_string()]; + + assert_eq!( + Decision::Prompt, + render_decision_for_unmatched_command( + AskForApproval::Reject(RejectConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }), + &SandboxPolicy::new_read_only_policy(), + &read_only_file_system_sandbox_policy(), + &command, + SandboxPermissions::RequireEscalated, + false, + ) + ); +} + +#[test] +fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { + let command = vec!["madeup-cmd".to_string()]; + let restricted_file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + + assert_eq!( + Decision::Prompt, + render_decision_for_unmatched_command( + AskForApproval::OnRequest, + &SandboxPolicy::DangerFullAccess, + &restricted_file_system_policy, + &command, + SandboxPermissions::RequireEscalated, + false, + ) + ); +} + +#[tokio::test] +async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_sandbox_rejection_enabled() + { + let command = vec!["madeup-cmd".to_string()]; + + let requirement = ExecPolicyManager::default() + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::Reject(RejectConfig { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }), + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Forbidden { + reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(), + } + ); +} + +#[tokio::test] +async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() { + let policy_src = r#"prefix_rule(pattern=["git"], decision="prompt")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let manager = ExecPolicyManager::new(Arc::new(parser.build())); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "git status && madeup-cmd".to_string(), + ]; + + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::Reject(RejectConfig { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }), + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + prefix_rule: None, + }) + .await; + + assert!(matches!( + requirement, + ExecApprovalRequirement::NeedsApproval { .. } + )); +} + +#[tokio::test] +async fn mixed_rule_and_sandbox_prompt_rejects_when_rules_rejection_enabled() { + let policy_src = r#"prefix_rule(pattern=["git"], decision="prompt")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let manager = ExecPolicyManager::new(Arc::new(parser.build())); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "git status && madeup-cmd".to_string(), + ]; + + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::Reject(RejectConfig { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }), + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Forbidden { + reason: REJECT_RULES_APPROVAL_REASON.to_string(), + } + ); +} + +#[tokio::test] +async fn exec_approval_requirement_falls_back_to_heuristics() { + let command = vec!["cargo".to_string(), "build".to_string()]; + + let manager = ExecPolicyManager::default(); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)) + } + ); +} + +#[tokio::test] +async fn empty_bash_lc_script_falls_back_to_original_command() { + let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()]; + + let manager = ExecPolicyManager::default(); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + } + ); +} + +#[tokio::test] +async fn whitespace_bash_lc_script_falls_back_to_original_command() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + " \n\t ".to_string(), + ]; + + let manager = ExecPolicyManager::default(); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + } + ); +} + +#[tokio::test] +async fn request_rule_uses_prefix_rule() { + let command = vec![ + "cargo".to_string(), + "install".to_string(), + "cargo-insta".to_string(), + ]; + let manager = ExecPolicyManager::default(); + + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "cargo".to_string(), + "install".to_string(), + ])), + } + ); +} + +#[tokio::test] +async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cargo install cargo-insta && rm -rf /tmp/codex".to_string(), + ]; + let manager = ExecPolicyManager::default(); + + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "rm".to_string(), + "-rf".to_string(), + "/tmp/codex".to_string(), + ])), + } + ); +} + +#[tokio::test] +async fn heuristics_apply_when_other_commands_match_policy() { + let policy_src = r#"prefix_rule(pattern=["apple"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let policy = Arc::new(parser.build()); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "apple | orange".to_string(), + ]; + + assert_eq!( + ExecPolicyManager::new(policy) + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "orange".to_string() + ])) + } + ); +} + +#[tokio::test] +async fn append_execpolicy_amendment_updates_policy_and_file() { + let codex_home = tempdir().expect("create temp dir"); + let prefix = vec!["echo".to_string(), "hello".to_string()]; + let manager = ExecPolicyManager::default(); + + manager + .append_amendment_and_update(codex_home.path(), &ExecPolicyAmendment::from(prefix)) + .await + .expect("update policy"); + let updated_policy = manager.current(); + + let evaluation = updated_policy.check( + &["echo".to_string(), "hello".to_string(), "world".to_string()], + &|_| Decision::Allow, + ); + assert!(matches!( + evaluation, + Evaluation { + decision: Decision::Allow, + .. + } + )); + + let contents = fs::read_to_string(default_policy_path(codex_home.path())) + .expect("policy file should have been created"); + assert_eq!( + contents, + r#"prefix_rule(pattern=["echo", "hello"], decision="allow") +"# + ); +} + +#[tokio::test] +async fn append_execpolicy_amendment_rejects_empty_prefix() { + let codex_home = tempdir().expect("create temp dir"); + let manager = ExecPolicyManager::default(); + + let result = manager + .append_amendment_and_update(codex_home.path(), &ExecPolicyAmendment::from(vec![])) + .await; + + assert!(matches!( + result, + Err(ExecPolicyUpdateError::AppendRule { + source: AmendError::EmptyPrefix, + .. + }) + )); +} + +#[tokio::test] +async fn proposed_execpolicy_amendment_is_present_for_single_command_without_policy_match() { + let command = vec!["cargo".to_string(), "build".to_string()]; + + let manager = ExecPolicyManager::default(); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)) + } + ); +} + +#[tokio::test] +async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { + let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let policy = Arc::new(parser.build()); + let command = vec!["rm".to_string()]; + + let manager = ExecPolicyManager::new(policy); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: Some("`rm` requires approval by policy".to_string()), + proposed_execpolicy_amendment: None, + } + ); +} + +#[tokio::test] +async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cargo build && echo ok".to_string(), + ]; + let manager = ExecPolicyManager::default(); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "cargo".to_string(), + "build".to_string() + ])), + } + ); +} + +#[tokio::test] +async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scripts() { + let policy_src = r#"prefix_rule(pattern=["cat"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let policy = Arc::new(parser.build()); + + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cat && apple".to_string(), + ]; + + assert_eq!( + ExecPolicyManager::new(policy) + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "apple".to_string() + ])), + } + ); +} + +#[tokio::test] +async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { + let command = vec!["echo".to_string(), "safe".to_string()]; + + let manager = ExecPolicyManager::default(); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + } + ); +} + +#[tokio::test] +async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() { + let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", policy_src) + .expect("parse policy"); + let policy = Arc::new(parser.build()); + let command = vec!["echo".to_string(), "safe".to_string()]; + + let manager = ExecPolicyManager::new(policy); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + } + ); +} + +fn derive_requested_execpolicy_amendment_for_test( + prefix_rule: Option<&Vec>, + matched_rules: &[RuleMatch], +) -> Option { + let commands = prefix_rule + .cloned() + .map(|prefix_rule| vec![prefix_rule]) + .unwrap_or_else(|| vec![vec!["echo".to_string()]]); + derive_requested_execpolicy_amendment_from_prefix_rule( + prefix_rule, + matched_rules, + &Policy::empty(), + &commands, + &|_: &[String]| Decision::Allow, + &MatchOptions::default(), + ) +} + +#[test] +fn derive_requested_execpolicy_amendment_returns_none_for_missing_prefix_rule() { + assert_eq!( + None, + derive_requested_execpolicy_amendment_for_test(None, &[]) + ); +} + +#[test] +fn derive_requested_execpolicy_amendment_returns_none_for_empty_prefix_rule() { + assert_eq!( + None, + derive_requested_execpolicy_amendment_for_test(Some(&Vec::new()), &[]) + ); +} + +#[test] +fn derive_requested_execpolicy_amendment_returns_none_for_exact_banned_prefix_rule() { + assert_eq!( + None, + derive_requested_execpolicy_amendment_for_test( + Some(&vec!["python".to_string(), "-c".to_string()]), + &[], + ) + ); +} + +#[test] +fn derive_requested_execpolicy_amendment_returns_none_for_windows_and_pypy_variants() { + for prefix_rule in [ + vec!["py".to_string()], + vec!["py".to_string(), "-3".to_string()], + vec!["pythonw".to_string()], + vec!["pyw".to_string()], + vec!["pypy".to_string()], + vec!["pypy3".to_string()], + ] { + assert_eq!( + None, + derive_requested_execpolicy_amendment_for_test(Some(&prefix_rule), &[]) + ); + } +} + +#[test] +fn derive_requested_execpolicy_amendment_returns_none_for_shell_and_powershell_variants() { + for prefix_rule in [ + vec!["bash".to_string(), "-lc".to_string()], + vec!["sh".to_string(), "-c".to_string()], + vec!["sh".to_string(), "-lc".to_string()], + vec!["zsh".to_string(), "-lc".to_string()], + vec!["/bin/bash".to_string(), "-lc".to_string()], + vec!["/bin/zsh".to_string(), "-lc".to_string()], + vec!["pwsh".to_string()], + vec!["pwsh".to_string(), "-Command".to_string()], + vec!["pwsh".to_string(), "-c".to_string()], + vec!["powershell".to_string()], + vec!["powershell".to_string(), "-Command".to_string()], + vec!["powershell".to_string(), "-c".to_string()], + vec!["powershell.exe".to_string()], + vec!["powershell.exe".to_string(), "-Command".to_string()], + vec!["powershell.exe".to_string(), "-c".to_string()], + ] { + assert_eq!( + None, + derive_requested_execpolicy_amendment_for_test(Some(&prefix_rule), &[]) + ); + } +} + +#[test] +fn derive_requested_execpolicy_amendment_allows_non_exact_banned_prefix_rule_match() { + let prefix_rule = vec![ + "python".to_string(), + "-c".to_string(), + "print('hi')".to_string(), + ]; + + assert_eq!( + Some(ExecPolicyAmendment::new(prefix_rule.clone())), + derive_requested_execpolicy_amendment_for_test(Some(&prefix_rule), &[]) + ); +} + +#[test] +fn derive_requested_execpolicy_amendment_returns_none_when_policy_matches() { + let prefix_rule = vec!["cargo".to_string(), "build".to_string()]; + + let matched_rules_prompt = vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["cargo".to_string()], + decision: Decision::Prompt, + resolved_program: None, + justification: None, + }]; + assert_eq!( + None, + derive_requested_execpolicy_amendment_for_test(Some(&prefix_rule), &matched_rules_prompt), + "should return none when prompt policy matches" + ); + let matched_rules_allow = vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["cargo".to_string()], + decision: Decision::Allow, + resolved_program: None, + justification: None, + }]; + assert_eq!( + None, + derive_requested_execpolicy_amendment_for_test(Some(&prefix_rule), &matched_rules_allow), + "should return none when prompt policy matches" + ); + let matched_rules_forbidden = vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["cargo".to_string()], + decision: Decision::Forbidden, + resolved_program: None, + justification: None, + }]; + assert_eq!( + None, + derive_requested_execpolicy_amendment_for_test( + Some(&prefix_rule), + &matched_rules_forbidden, + ), + "should return none when prompt policy matches" + ); +} + +#[tokio::test] +async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { + let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]); + let manager = ExecPolicyManager::default(); + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + } + ); +} + +fn vec_str(items: &[&str]) -> Vec { + items.iter().map(std::string::ToString::to_string).collect() +} + +/// Note this test behaves differently on Windows because it exercises an +/// `if cfg!(windows)` code path in render_decision_for_unmatched_command(). +#[tokio::test] +async fn verify_approval_requirement_for_unsafe_powershell_command() { + // `brew install powershell` to run this test on a Mac! + // Note `pwsh` is required to parse a PowerShell command to see if it + // is safe. + if which::which("pwsh").is_err() { + return; + } + + let policy = ExecPolicyManager::new(Arc::new(Policy::empty())); + let permissions = SandboxPermissions::UseDefault; + + // This command should not be run without user approval unless there is + // a proper sandbox in place to ensure safety. + let sneaky_command = vec_str(&["pwsh", "-Command", "echo hi @(calc)"]); + let expected_amendment = Some(ExecPolicyAmendment::new(vec_str(&[ + "pwsh", + "-Command", + "echo hi @(calc)", + ]))); + let (pwsh_approval_reason, expected_req) = if cfg!(windows) { + ( + r#"On Windows, SandboxPolicy::ReadOnly should be assumed to mean + that no sandbox is present, so anything that is not "provably + safe" should require approval."#, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: expected_amendment.clone(), + }, + ) + } else { + ( + "On non-Windows, rely on the read-only sandbox to prevent harm.", + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: expected_amendment.clone(), + }, + ) + }; + assert_eq!( + expected_req, + policy + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &sneaky_command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: permissions, + prefix_rule: None, + }) + .await, + "{pwsh_approval_reason}" + ); + + // This is flagged as a dangerous command on all platforms. + let dangerous_command = vec_str(&["rm", "-rf", "/important/data"]); + assert_eq!( + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec_str(&[ + "rm", + "-rf", + "/important/data", + ]))), + }, + policy + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &dangerous_command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: permissions, + prefix_rule: None, + }) + .await, + r#"On all platforms, a forbidden command should require approval + (unless AskForApproval::Never is specified)."# + ); + + // A dangerous command should be forbidden if the user has specified + // AskForApproval::Never. + assert_eq!( + ExecApprovalRequirement::Forbidden { + reason: "`rm -rf /important/data` rejected: blocked by policy".to_string(), + }, + policy + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &dangerous_command, + approval_policy: AskForApproval::Never, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: permissions, + prefix_rule: None, + }) + .await, + r#"On all platforms, a forbidden command should require approval + (unless AskForApproval::Never is specified)."# + ); +} diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs new file mode 100644 index 0000000000..550b41af7f --- /dev/null +++ b/codex-rs/core/src/exec_tests.rs @@ -0,0 +1,423 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::time::Duration; +use tokio::io::AsyncWriteExt; + +fn make_exec_output( + exit_code: i32, + stdout: &str, + stderr: &str, + aggregated: &str, +) -> ExecToolCallOutput { + ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(stdout.to_string()), + stderr: StreamOutput::new(stderr.to_string()), + aggregated_output: StreamOutput::new(aggregated.to_string()), + duration: Duration::from_millis(1), + timed_out: false, + } +} + +#[test] +fn sandbox_detection_requires_keywords() { + let output = make_exec_output(1, "", "", ""); + assert!(!is_likely_sandbox_denied( + SandboxType::LinuxSeccomp, + &output + )); +} + +#[test] +fn sandbox_detection_identifies_keyword_in_stderr() { + let output = make_exec_output(1, "", "Operation not permitted", ""); + assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output)); +} + +#[test] +fn sandbox_detection_respects_quick_reject_exit_codes() { + let output = make_exec_output(127, "", "command not found", ""); + assert!(!is_likely_sandbox_denied( + SandboxType::LinuxSeccomp, + &output + )); +} + +#[test] +fn sandbox_detection_ignores_non_sandbox_mode() { + let output = make_exec_output(1, "", "Operation not permitted", ""); + assert!(!is_likely_sandbox_denied(SandboxType::None, &output)); +} + +#[test] +fn sandbox_detection_ignores_network_policy_text_in_non_sandbox_mode() { + let output = make_exec_output( + 0, + "", + "", + r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","reason":"not_allowed","source":"decider","protocol":"http","host":"google.com","port":80}"#, + ); + assert!(!is_likely_sandbox_denied(SandboxType::None, &output)); +} + +#[test] +fn sandbox_detection_uses_aggregated_output() { + let output = make_exec_output( + 101, + "", + "", + "cargo failed: Read-only file system when writing target", + ); + assert!(is_likely_sandbox_denied( + SandboxType::MacosSeatbelt, + &output + )); +} + +#[test] +fn sandbox_detection_ignores_network_policy_text_with_zero_exit_code() { + let output = make_exec_output( + 0, + "", + "", + r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","source":"decider","protocol":"http","host":"google.com","port":80}"#, + ); + + assert!(!is_likely_sandbox_denied( + SandboxType::LinuxSeccomp, + &output + )); +} + +#[tokio::test] +async fn read_capped_limits_retained_bytes() { + let (mut writer, reader) = tokio::io::duplex(1024); + let bytes = vec![b'a'; EXEC_OUTPUT_MAX_BYTES.saturating_add(128 * 1024)]; + tokio::spawn(async move { + writer.write_all(&bytes).await.expect("write"); + }); + + let out = read_capped(reader, None, false).await.expect("read"); + assert_eq!(out.text.len(), EXEC_OUTPUT_MAX_BYTES); +} + +#[test] +fn aggregate_output_prefers_stderr_on_contention() { + let stdout = StreamOutput { + text: vec![b'a'; EXEC_OUTPUT_MAX_BYTES], + truncated_after_lines: None, + }; + let stderr = StreamOutput { + text: vec![b'b'; EXEC_OUTPUT_MAX_BYTES], + truncated_after_lines: None, + }; + + let aggregated = aggregate_output(&stdout, &stderr); + let stdout_cap = EXEC_OUTPUT_MAX_BYTES / 3; + let stderr_cap = EXEC_OUTPUT_MAX_BYTES.saturating_sub(stdout_cap); + + assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES); + assert_eq!(aggregated.text[..stdout_cap], vec![b'a'; stdout_cap]); + assert_eq!(aggregated.text[stdout_cap..], vec![b'b'; stderr_cap]); +} + +#[test] +fn aggregate_output_fills_remaining_capacity_with_stderr() { + let stdout_len = EXEC_OUTPUT_MAX_BYTES / 10; + let stdout = StreamOutput { + text: vec![b'a'; stdout_len], + truncated_after_lines: None, + }; + let stderr = StreamOutput { + text: vec![b'b'; EXEC_OUTPUT_MAX_BYTES], + truncated_after_lines: None, + }; + + let aggregated = aggregate_output(&stdout, &stderr); + let stderr_cap = EXEC_OUTPUT_MAX_BYTES.saturating_sub(stdout_len); + + assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES); + assert_eq!(aggregated.text[..stdout_len], vec![b'a'; stdout_len]); + assert_eq!(aggregated.text[stdout_len..], vec![b'b'; stderr_cap]); +} + +#[test] +fn aggregate_output_rebalances_when_stderr_is_small() { + let stdout = StreamOutput { + text: vec![b'a'; EXEC_OUTPUT_MAX_BYTES], + truncated_after_lines: None, + }; + let stderr = StreamOutput { + text: vec![b'b'; 1], + truncated_after_lines: None, + }; + + let aggregated = aggregate_output(&stdout, &stderr); + let stdout_len = EXEC_OUTPUT_MAX_BYTES.saturating_sub(1); + + assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES); + assert_eq!(aggregated.text[..stdout_len], vec![b'a'; stdout_len]); + assert_eq!(aggregated.text[stdout_len..], vec![b'b'; 1]); +} + +#[test] +fn aggregate_output_keeps_stdout_then_stderr_when_under_cap() { + let stdout = StreamOutput { + text: vec![b'a'; 4], + truncated_after_lines: None, + }; + let stderr = StreamOutput { + text: vec![b'b'; 3], + truncated_after_lines: None, + }; + + let aggregated = aggregate_output(&stdout, &stderr); + let mut expected = Vec::new(); + expected.extend_from_slice(&stdout.text); + expected.extend_from_slice(&stderr.text); + + assert_eq!(aggregated.text, expected); + assert_eq!(aggregated.truncated_after_lines, None); +} + +#[test] +fn windows_restricted_token_skips_external_sandbox_policies() { + let policy = SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Restricted, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + + assert_eq!( + should_use_windows_restricted_token_sandbox( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + ), + false + ); +} + +#[test] +fn windows_restricted_token_runs_for_legacy_restricted_policies() { + let policy = SandboxPolicy::new_read_only_policy(); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + + assert_eq!( + should_use_windows_restricted_token_sandbox( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + ), + true + ); +} + +#[test] +fn windows_restricted_token_rejects_network_only_restrictions() { + let policy = SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Restricted, + }; + let file_system_policy = FileSystemSandboxPolicy::unrestricted(); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ), + Some( + "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_allows_legacy_restricted_policies() { + let policy = SandboxPolicy::new_read_only_policy(); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ), + None + ); +} + +#[test] +fn windows_restricted_token_allows_legacy_workspace_write_policies() { + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let file_system_policy = FileSystemSandboxPolicy::from(&policy); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ), + None + ); +} + +#[test] +fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() { + let expected = crate::get_platform_sandbox(false).unwrap_or(SandboxType::None); + + assert_eq!( + select_process_exec_tool_sandbox_type( + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Restricted, + codex_protocol::config_types::WindowsSandboxLevel::Disabled, + false, + ), + expected + ); +} + +#[cfg(unix)] +#[test] +fn sandbox_detection_flags_sigsys_exit_code() { + let exit_code = EXIT_CODE_SIGNAL_BASE + libc::SIGSYS; + let output = make_exec_output(exit_code, "", "", ""); + assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output)); +} + +#[cfg(unix)] +#[tokio::test] +async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> { + // On Linux/macOS, /bin/bash is typically present; on FreeBSD/OpenBSD, + // prefer /bin/sh to avoid NotFound errors. + #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] + let command = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 60 & echo $!; sleep 60".to_string(), + ]; + #[cfg(all(unix, not(any(target_os = "freebsd", target_os = "openbsd"))))] + let command = vec![ + "/bin/bash".to_string(), + "-c".to_string(), + "sleep 60 & echo $!; sleep 60".to_string(), + ]; + let env: HashMap = std::env::vars().collect(); + let params = ExecParams { + command, + cwd: std::env::current_dir()?, + expiration: 500.into(), + env, + network: None, + sandbox_permissions: SandboxPermissions::UseDefault, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + justification: None, + arg0: None, + }; + + let output = exec( + params, + SandboxType::None, + &SandboxPolicy::new_read_only_policy(), + &FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()), + NetworkSandboxPolicy::Restricted, + None, + None, + ) + .await?; + assert!(output.timed_out); + + let stdout = output.stdout.from_utf8_lossy().text; + let pid_line = stdout.lines().next().unwrap_or("").trim(); + let pid: i32 = pid_line.parse().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to parse pid from stdout '{pid_line}': {error}"), + ) + })?; + + let mut killed = false; + for _ in 0..20 { + // Use kill(pid, 0) to check if the process is alive. + if unsafe { libc::kill(pid, 0) } == -1 + && let Some(libc::ESRCH) = std::io::Error::last_os_error().raw_os_error() + { + killed = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + assert!(killed, "grandchild process with pid {pid} is still alive"); + Ok(()) +} + +#[tokio::test] +async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { + let command = long_running_command(); + let cwd = std::env::current_dir()?; + let env: HashMap = std::env::vars().collect(); + let cancel_token = CancellationToken::new(); + let cancel_tx = cancel_token.clone(); + let params = ExecParams { + command, + cwd: cwd.clone(), + expiration: ExecExpiration::Cancellation(cancel_token), + env, + network: None, + sandbox_permissions: SandboxPermissions::UseDefault, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + justification: None, + arg0: None, + }; + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(1_000)).await; + cancel_tx.cancel(); + }); + let result = process_exec_tool_call( + params, + &SandboxPolicy::DangerFullAccess, + &FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), + NetworkSandboxPolicy::Enabled, + cwd.as_path(), + &None, + false, + None, + ) + .await; + let output = match result { + Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => output, + other => panic!("expected timeout error, got {other:?}"), + }; + assert!(output.timed_out); + assert_eq!(output.exit_code, EXEC_TIMEOUT_EXIT_CODE); + Ok(()) +} + +#[cfg(unix)] +fn long_running_command() -> Vec { + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ] +} + +#[cfg(windows)] +fn long_running_command() -> Vec { + vec![ + "powershell.exe".to_string(), + "-NonInteractive".to_string(), + "-NoLogo".to_string(), + "-Command".to_string(), + "Start-Sleep -Seconds 30".to_string(), + ] +} diff --git a/codex-rs/core/src/external_agent_config.rs b/codex-rs/core/src/external_agent_config.rs index 24319fe064..4df4d60ebe 100644 --- a/codex-rs/core/src/external_agent_config.rs +++ b/codex-rs/core/src/external_agent_config.rs @@ -688,402 +688,5 @@ fn emit_migration_metric( } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::TempDir; - - fn fixture_paths() -> (TempDir, PathBuf, PathBuf) { - let root = TempDir::new().expect("create tempdir"); - let claude_home = root.path().join(".claude"); - let codex_home = root.path().join(".codex"); - (root, claude_home, codex_home) - } - - fn service_for_paths(claude_home: PathBuf, codex_home: PathBuf) -> ExternalAgentConfigService { - ExternalAgentConfigService::new_for_test(codex_home, claude_home) - } - - #[test] - fn detect_home_lists_config_skills_and_agents_md() { - let (_root, claude_home, codex_home) = fixture_paths(); - let agents_skills = codex_home - .parent() - .map(|parent| parent.join(".agents").join("skills")) - .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); - fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create skills"); - fs::write(claude_home.join("CLAUDE.md"), "claude rules").expect("write claude md"); - fs::write( - claude_home.join("settings.json"), - r#"{"model":"claude","env":{"FOO":"bar"}}"#, - ) - .expect("write settings"); - - let items = service_for_paths(claude_home.clone(), codex_home.clone()) - .detect(ExternalAgentConfigDetectOptions { - include_home: true, - cwds: None, - }) - .expect("detect"); - - let expected = vec![ - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Config, - description: format!( - "Migrate {} into {}", - claude_home.join("settings.json").display(), - codex_home.join("config.toml").display() - ), - cwd: None, - }, - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Skills, - description: format!( - "Copy skill folders from {} to {}", - claude_home.join("skills").display(), - agents_skills.display() - ), - cwd: None, - }, - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::AgentsMd, - description: format!( - "Import {} to {}", - claude_home.join("CLAUDE.md").display(), - codex_home.join("AGENTS.md").display() - ), - cwd: None, - }, - ]; - - assert_eq!(items, expected); - } - - #[test] - fn detect_repo_lists_agents_md_for_each_cwd() { - let root = TempDir::new().expect("create tempdir"); - let repo_root = root.path().join("repo"); - let nested = repo_root.join("nested").join("child"); - fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); - fs::create_dir_all(&nested).expect("create nested"); - fs::write(repo_root.join("CLAUDE.md"), "Claude code guidance").expect("write source"); - - let items = service_for_paths(root.path().join(".claude"), root.path().join(".codex")) - .detect(ExternalAgentConfigDetectOptions { - include_home: false, - cwds: Some(vec![nested, repo_root.clone()]), - }) - .expect("detect"); - - let expected = vec![ - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::AgentsMd, - description: format!( - "Import {} to {}", - repo_root.join("CLAUDE.md").display(), - repo_root.join("AGENTS.md").display(), - ), - cwd: Some(repo_root.clone()), - }, - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::AgentsMd, - description: format!( - "Import {} to {}", - repo_root.join("CLAUDE.md").display(), - repo_root.join("AGENTS.md").display(), - ), - cwd: Some(repo_root), - }, - ]; - - assert_eq!(items, expected); - } - - #[test] - fn import_home_migrates_supported_config_fields_skills_and_agents_md() { - let (_root, claude_home, codex_home) = fixture_paths(); - let agents_skills = codex_home - .parent() - .map(|parent| parent.join(".agents").join("skills")) - .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); - fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create skills"); - fs::write( - claude_home.join("settings.json"), - r#"{"model":"claude","permissions":{"ask":["git push"]},"env":{"FOO":"bar","CI":false,"MAX_RETRIES":3,"MY_TEAM":"codex","IGNORED":null,"LIST":["a","b"],"MAP":{"x":1}},"sandbox":{"enabled":true,"network":{"allowLocalBinding":true}}}"#, - ) - .expect("write settings"); - fs::write( - claude_home.join("skills").join("skill-a").join("SKILL.md"), - "Use Claude Code and CLAUDE utilities.", - ) - .expect("write skill"); - fs::write(claude_home.join("CLAUDE.md"), "Claude code guidance").expect("write agents"); - - service_for_paths(claude_home, codex_home.clone()) - .import(vec![ - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::AgentsMd, - description: String::new(), - cwd: None, - }, - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Config, - description: String::new(), - cwd: None, - }, - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Skills, - description: String::new(), - cwd: None, - }, - ]) - .expect("import"); - - assert_eq!( - fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"), - "Codex guidance" - ); - - assert_eq!( - fs::read_to_string(codex_home.join("config.toml")).expect("read config"), - "sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nCI = \"false\"\nFOO = \"bar\"\nMAX_RETRIES = \"3\"\nMY_TEAM = \"codex\"\n" - ); - assert_eq!( - fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md")) - .expect("read copied skill"), - "Use Codex and Codex utilities." - ); - } - - #[test] - fn import_home_skips_empty_config_migration() { - let (_root, claude_home, codex_home) = fixture_paths(); - fs::create_dir_all(&claude_home).expect("create claude home"); - fs::write( - claude_home.join("settings.json"), - r#"{"model":"claude","sandbox":{"enabled":false}}"#, - ) - .expect("write settings"); - - service_for_paths(claude_home, codex_home.clone()) - .import(vec![ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Config, - description: String::new(), - cwd: None, - }]) - .expect("import"); - - assert!(!codex_home.join("config.toml").exists()); - } - - #[test] - fn detect_home_skips_config_when_target_already_has_supported_fields() { - let (_root, claude_home, codex_home) = fixture_paths(); - fs::create_dir_all(&claude_home).expect("create claude home"); - fs::create_dir_all(&codex_home).expect("create codex home"); - fs::write( - claude_home.join("settings.json"), - r#"{"env":{"FOO":"bar"},"sandbox":{"enabled":true}}"#, - ) - .expect("write settings"); - fs::write( - codex_home.join("config.toml"), - r#" - sandbox_mode = "workspace-write" - - [shell_environment_policy] - inherit = "core" - - [shell_environment_policy.set] - FOO = "bar" - "#, - ) - .expect("write config"); - - let items = service_for_paths(claude_home, codex_home) - .detect(ExternalAgentConfigDetectOptions { - include_home: true, - cwds: None, - }) - .expect("detect"); - - assert_eq!(items, Vec::::new()); - } - - #[test] - fn detect_home_skips_skills_when_all_skill_directories_exist() { - let (_root, claude_home, codex_home) = fixture_paths(); - let agents_skills = codex_home - .parent() - .map(|parent| parent.join(".agents").join("skills")) - .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); - fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create source"); - fs::create_dir_all(agents_skills.join("skill-a")).expect("create target"); - - let items = service_for_paths(claude_home, codex_home) - .detect(ExternalAgentConfigDetectOptions { - include_home: true, - cwds: None, - }) - .expect("detect"); - - assert_eq!(items, Vec::::new()); - } - - #[test] - fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { - let root = TempDir::new().expect("create tempdir"); - let repo_root = root.path().join("repo-a"); - let repo_with_existing_target = root.path().join("repo-b"); - fs::create_dir_all(repo_root.join(".git")).expect("create git"); - fs::create_dir_all(repo_with_existing_target.join(".git")).expect("create git"); - fs::write( - repo_root.join("CLAUDE.md"), - "Claude code\nclaude\nCLAUDE-CODE\nSee CLAUDE.md\n", - ) - .expect("write source"); - fs::write(repo_with_existing_target.join("CLAUDE.md"), "new source").expect("write source"); - fs::write( - repo_with_existing_target.join("AGENTS.md"), - "keep existing target", - ) - .expect("write target"); - - service_for_paths(root.path().join(".claude"), root.path().join(".codex")) - .import(vec![ - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::AgentsMd, - description: String::new(), - cwd: Some(repo_root.clone()), - }, - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::AgentsMd, - description: String::new(), - cwd: Some(repo_with_existing_target.clone()), - }, - ]) - .expect("import"); - - assert_eq!( - fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), - "Codex\nCodex\nCodex\nSee AGENTS.md\n" - ); - assert_eq!( - fs::read_to_string(repo_with_existing_target.join("AGENTS.md")) - .expect("read existing target"), - "keep existing target" - ); - } - - #[test] - fn import_repo_agents_md_overwrites_empty_targets() { - let root = TempDir::new().expect("create tempdir"); - let repo_root = root.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).expect("create git"); - fs::write(repo_root.join("CLAUDE.md"), "Claude code guidance").expect("write source"); - fs::write(repo_root.join("AGENTS.md"), " \n\t").expect("write empty target"); - - service_for_paths(root.path().join(".claude"), root.path().join(".codex")) - .import(vec![ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::AgentsMd, - description: String::new(), - cwd: Some(repo_root.clone()), - }]) - .expect("import"); - - assert_eq!( - fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), - "Codex guidance" - ); - } - - #[test] - fn detect_repo_prefers_non_empty_dot_claude_agents_source() { - let root = TempDir::new().expect("create tempdir"); - let repo_root = root.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).expect("create git"); - fs::create_dir_all(repo_root.join(".claude")).expect("create dot claude"); - fs::write(repo_root.join("CLAUDE.md"), " \n\t").expect("write empty root source"); - fs::write( - repo_root.join(".claude").join("CLAUDE.md"), - "Claude code guidance", - ) - .expect("write dot claude source"); - - let items = service_for_paths(root.path().join(".claude"), root.path().join(".codex")) - .detect(ExternalAgentConfigDetectOptions { - include_home: false, - cwds: Some(vec![repo_root.clone()]), - }) - .expect("detect"); - - assert_eq!( - items, - vec![ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::AgentsMd, - description: format!( - "Import {} to {}", - repo_root.join(".claude").join("CLAUDE.md").display(), - repo_root.join("AGENTS.md").display(), - ), - cwd: Some(repo_root), - }] - ); - } - - #[test] - fn import_repo_uses_non_empty_dot_claude_agents_source() { - let root = TempDir::new().expect("create tempdir"); - let repo_root = root.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).expect("create git"); - fs::create_dir_all(repo_root.join(".claude")).expect("create dot claude"); - fs::write(repo_root.join("CLAUDE.md"), "").expect("write empty root source"); - fs::write( - repo_root.join(".claude").join("CLAUDE.md"), - "Claude code guidance", - ) - .expect("write dot claude source"); - - service_for_paths(root.path().join(".claude"), root.path().join(".codex")) - .import(vec![ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::AgentsMd, - description: String::new(), - cwd: Some(repo_root.clone()), - }]) - .expect("import"); - - assert_eq!( - fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), - "Codex guidance" - ); - } - - #[test] - fn migration_metric_tags_for_skills_include_skills_count() { - assert_eq!( - migration_metric_tags(ExternalAgentConfigMigrationItemType::Skills, Some(3)), - vec![ - ("migration_type", "skills".to_string()), - ("skills_count", "3".to_string()), - ] - ); - } - - #[test] - fn import_skills_returns_only_new_skill_directory_count() { - let (_root, claude_home, codex_home) = fixture_paths(); - let agents_skills = codex_home - .parent() - .map(|parent| parent.join(".agents").join("skills")) - .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); - fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create source a"); - fs::create_dir_all(claude_home.join("skills").join("skill-b")).expect("create source b"); - fs::create_dir_all(agents_skills.join("skill-a")).expect("create existing target"); - - let copied_count = service_for_paths(claude_home, codex_home) - .import_skills(None) - .expect("import skills"); - - assert_eq!(copied_count, 1); - } -} +#[path = "external_agent_config_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/external_agent_config_tests.rs b/codex-rs/core/src/external_agent_config_tests.rs new file mode 100644 index 0000000000..a760f73e19 --- /dev/null +++ b/codex-rs/core/src/external_agent_config_tests.rs @@ -0,0 +1,397 @@ +use super::*; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +fn fixture_paths() -> (TempDir, PathBuf, PathBuf) { + let root = TempDir::new().expect("create tempdir"); + let claude_home = root.path().join(".claude"); + let codex_home = root.path().join(".codex"); + (root, claude_home, codex_home) +} + +fn service_for_paths(claude_home: PathBuf, codex_home: PathBuf) -> ExternalAgentConfigService { + ExternalAgentConfigService::new_for_test(codex_home, claude_home) +} + +#[test] +fn detect_home_lists_config_skills_and_agents_md() { + let (_root, claude_home, codex_home) = fixture_paths(); + let agents_skills = codex_home + .parent() + .map(|parent| parent.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); + fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create skills"); + fs::write(claude_home.join("CLAUDE.md"), "claude rules").expect("write claude md"); + fs::write( + claude_home.join("settings.json"), + r#"{"model":"claude","env":{"FOO":"bar"}}"#, + ) + .expect("write settings"); + + let items = service_for_paths(claude_home.clone(), codex_home.clone()) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .expect("detect"); + + let expected = vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: format!( + "Migrate {} into {}", + claude_home.join("settings.json").display(), + codex_home.join("config.toml").display() + ), + cwd: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: format!( + "Copy skill folders from {} to {}", + claude_home.join("skills").display(), + agents_skills.display() + ), + cwd: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: format!( + "Import {} to {}", + claude_home.join("CLAUDE.md").display(), + codex_home.join("AGENTS.md").display() + ), + cwd: None, + }, + ]; + + assert_eq!(items, expected); +} + +#[test] +fn detect_repo_lists_agents_md_for_each_cwd() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + let nested = repo_root.join("nested").join("child"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(&nested).expect("create nested"); + fs::write(repo_root.join("CLAUDE.md"), "Claude code guidance").expect("write source"); + + let items = service_for_paths(root.path().join(".claude"), root.path().join(".codex")) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![nested, repo_root.clone()]), + }) + .expect("detect"); + + let expected = vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: format!( + "Import {} to {}", + repo_root.join("CLAUDE.md").display(), + repo_root.join("AGENTS.md").display(), + ), + cwd: Some(repo_root.clone()), + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: format!( + "Import {} to {}", + repo_root.join("CLAUDE.md").display(), + repo_root.join("AGENTS.md").display(), + ), + cwd: Some(repo_root), + }, + ]; + + assert_eq!(items, expected); +} + +#[test] +fn import_home_migrates_supported_config_fields_skills_and_agents_md() { + let (_root, claude_home, codex_home) = fixture_paths(); + let agents_skills = codex_home + .parent() + .map(|parent| parent.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); + fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create skills"); + fs::write( + claude_home.join("settings.json"), + r#"{"model":"claude","permissions":{"ask":["git push"]},"env":{"FOO":"bar","CI":false,"MAX_RETRIES":3,"MY_TEAM":"codex","IGNORED":null,"LIST":["a","b"],"MAP":{"x":1}},"sandbox":{"enabled":true,"network":{"allowLocalBinding":true}}}"#, + ) + .expect("write settings"); + fs::write( + claude_home.join("skills").join("skill-a").join("SKILL.md"), + "Use Claude Code and CLAUDE utilities.", + ) + .expect("write skill"); + fs::write(claude_home.join("CLAUDE.md"), "Claude code guidance").expect("write agents"); + + service_for_paths(claude_home, codex_home.clone()) + .import(vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: String::new(), + cwd: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: String::new(), + cwd: None, + }, + ]) + .expect("import"); + + assert_eq!( + fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"), + "Codex guidance" + ); + + assert_eq!( + fs::read_to_string(codex_home.join("config.toml")).expect("read config"), + "sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nCI = \"false\"\nFOO = \"bar\"\nMAX_RETRIES = \"3\"\nMY_TEAM = \"codex\"\n" + ); + assert_eq!( + fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md")) + .expect("read copied skill"), + "Use Codex and Codex utilities." + ); +} + +#[test] +fn import_home_skips_empty_config_migration() { + let (_root, claude_home, codex_home) = fixture_paths(); + fs::create_dir_all(&claude_home).expect("create claude home"); + fs::write( + claude_home.join("settings.json"), + r#"{"model":"claude","sandbox":{"enabled":false}}"#, + ) + .expect("write settings"); + + service_for_paths(claude_home, codex_home.clone()) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: String::new(), + cwd: None, + }]) + .expect("import"); + + assert!(!codex_home.join("config.toml").exists()); +} + +#[test] +fn detect_home_skips_config_when_target_already_has_supported_fields() { + let (_root, claude_home, codex_home) = fixture_paths(); + fs::create_dir_all(&claude_home).expect("create claude home"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::write( + claude_home.join("settings.json"), + r#"{"env":{"FOO":"bar"},"sandbox":{"enabled":true}}"#, + ) + .expect("write settings"); + fs::write( + codex_home.join("config.toml"), + r#" + sandbox_mode = "workspace-write" + + [shell_environment_policy] + inherit = "core" + + [shell_environment_policy.set] + FOO = "bar" + "#, + ) + .expect("write config"); + + let items = service_for_paths(claude_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .expect("detect"); + + assert_eq!(items, Vec::::new()); +} + +#[test] +fn detect_home_skips_skills_when_all_skill_directories_exist() { + let (_root, claude_home, codex_home) = fixture_paths(); + let agents_skills = codex_home + .parent() + .map(|parent| parent.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); + fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create source"); + fs::create_dir_all(agents_skills.join("skill-a")).expect("create target"); + + let items = service_for_paths(claude_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .expect("detect"); + + assert_eq!(items, Vec::::new()); +} + +#[test] +fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo-a"); + let repo_with_existing_target = root.path().join("repo-b"); + fs::create_dir_all(repo_root.join(".git")).expect("create git"); + fs::create_dir_all(repo_with_existing_target.join(".git")).expect("create git"); + fs::write( + repo_root.join("CLAUDE.md"), + "Claude code\nclaude\nCLAUDE-CODE\nSee CLAUDE.md\n", + ) + .expect("write source"); + fs::write(repo_with_existing_target.join("CLAUDE.md"), "new source").expect("write source"); + fs::write( + repo_with_existing_target.join("AGENTS.md"), + "keep existing target", + ) + .expect("write target"); + + service_for_paths(root.path().join(".claude"), root.path().join(".codex")) + .import(vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: Some(repo_root.clone()), + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: Some(repo_with_existing_target.clone()), + }, + ]) + .expect("import"); + + assert_eq!( + fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), + "Codex\nCodex\nCodex\nSee AGENTS.md\n" + ); + assert_eq!( + fs::read_to_string(repo_with_existing_target.join("AGENTS.md")) + .expect("read existing target"), + "keep existing target" + ); +} + +#[test] +fn import_repo_agents_md_overwrites_empty_targets() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git"); + fs::write(repo_root.join("CLAUDE.md"), "Claude code guidance").expect("write source"); + fs::write(repo_root.join("AGENTS.md"), " \n\t").expect("write empty target"); + + service_for_paths(root.path().join(".claude"), root.path().join(".codex")) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: Some(repo_root.clone()), + }]) + .expect("import"); + + assert_eq!( + fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), + "Codex guidance" + ); +} + +#[test] +fn detect_repo_prefers_non_empty_dot_claude_agents_source() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git"); + fs::create_dir_all(repo_root.join(".claude")).expect("create dot claude"); + fs::write(repo_root.join("CLAUDE.md"), " \n\t").expect("write empty root source"); + fs::write( + repo_root.join(".claude").join("CLAUDE.md"), + "Claude code guidance", + ) + .expect("write dot claude source"); + + let items = service_for_paths(root.path().join(".claude"), root.path().join(".codex")) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: format!( + "Import {} to {}", + repo_root.join(".claude").join("CLAUDE.md").display(), + repo_root.join("AGENTS.md").display(), + ), + cwd: Some(repo_root), + }] + ); +} + +#[test] +fn import_repo_uses_non_empty_dot_claude_agents_source() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git"); + fs::create_dir_all(repo_root.join(".claude")).expect("create dot claude"); + fs::write(repo_root.join("CLAUDE.md"), "").expect("write empty root source"); + fs::write( + repo_root.join(".claude").join("CLAUDE.md"), + "Claude code guidance", + ) + .expect("write dot claude source"); + + service_for_paths(root.path().join(".claude"), root.path().join(".codex")) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: Some(repo_root.clone()), + }]) + .expect("import"); + + assert_eq!( + fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), + "Codex guidance" + ); +} + +#[test] +fn migration_metric_tags_for_skills_include_skills_count() { + assert_eq!( + migration_metric_tags(ExternalAgentConfigMigrationItemType::Skills, Some(3)), + vec![ + ("migration_type", "skills".to_string()), + ("skills_count", "3".to_string()), + ] + ); +} + +#[test] +fn import_skills_returns_only_new_skill_directory_count() { + let (_root, claude_home, codex_home) = fixture_paths(); + let agents_skills = codex_home + .parent() + .map(|parent| parent.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); + fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create source a"); + fs::create_dir_all(claude_home.join("skills").join("skill-b")).expect("create source b"); + fs::create_dir_all(agents_skills.join("skill-a")).expect("create existing target"); + + let copied_count = service_for_paths(claude_home, codex_home) + .import_skills(None) + .expect("import skills"); + + assert_eq!(copied_count, 1); +} diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 991dccd24b..5eb14bfc6d 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -108,8 +108,12 @@ pub enum Feature { WebSearchCached, /// Legacy search-tool feature flag kept for backward compatibility. SearchTool, - /// Use the bubblewrap-based Linux sandbox pipeline. + /// Removed legacy Linux bubblewrap opt-in flag retained as a no-op so old + /// wrappers and config can still parse it. UseLinuxSandboxBwrap, + /// Use the legacy Landlock Linux sandbox fallback instead of the default + /// bubblewrap pipeline. + UseLegacyLandlock, /// Allow the model to request approval and propose exec rules. RequestRule, /// Enable Windows sandbox (restricted token) on Windows. @@ -130,7 +134,7 @@ pub enum Feature { MemoryTool, /// Append additional AGENTS.md guidance to user instructions. ChildAgentsMd, - /// Allow `detail: "original"` image outputs on supported models. + /// Allow the model to request `detail: "original"` image outputs on supported models. ImageDetailOriginal, /// Enforce UTF8 output in Powershell. PowershellUtf8, @@ -142,6 +146,8 @@ pub enum Feature { SpawnCsv, /// Enable apps. Apps, + /// Enable discoverable tool suggestions for apps. + ToolSuggest, /// Enable plugins. Plugins, /// Allow the model to invoke the built-in image generation tool. @@ -284,6 +290,10 @@ impl Features { self.enabled(Feature::Apps) && auth.is_some_and(CodexAuth::is_chatgpt_auth) } + pub fn use_legacy_landlock(&self) -> bool { + self.enabled(Feature::UseLegacyLandlock) + } + pub fn enable(&mut self, f: Feature) -> &mut Self { self.enabled.insert(f); self @@ -638,14 +648,13 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::UseLinuxSandboxBwrap, key: "use_linux_sandbox_bwrap", - #[cfg(target_os = "linux")] - stage: Stage::Experimental { - name: "Bubblewrap sandbox", - menu_description: "Try the new linux sandbox based on bubblewrap.", - announcement: "NEW: Linux bubblewrap sandbox offers stronger filesystem and network controls than Landlock alone, including keeping .git and .codex read-only inside writable workspaces. Enable it in /experimental and restart Codex to try it.", - }, - #[cfg(not(target_os = "linux"))] - stage: Stage::UnderDevelopment, + stage: Stage::Removed, + default_enabled: false, + }, + FeatureSpec { + id: Feature::UseLegacyLandlock, + key: "use_legacy_landlock", + stage: Stage::Stable, default_enabled: false, }, FeatureSpec { @@ -716,6 +725,12 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::ToolSuggest, + key: "tool_suggest", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Plugins, key: "plugins", @@ -900,155 +915,5 @@ pub fn maybe_push_unstable_features_warning( } #[cfg(test)] -mod tests { - use super::*; - - use pretty_assertions::assert_eq; - - #[test] - fn under_development_features_are_disabled_by_default() { - for spec in FEATURES { - if matches!(spec.stage, Stage::UnderDevelopment) { - assert_eq!( - spec.default_enabled, false, - "feature `{}` is under development and must be disabled by default", - spec.key - ); - } - } - } - - #[test] - fn default_enabled_features_are_stable() { - for spec in FEATURES { - if spec.default_enabled { - assert!( - matches!(spec.stage, Stage::Stable | Stage::Removed), - "feature `{}` is enabled by default but is not stable/removed ({:?})", - spec.key, - spec.stage - ); - } - } - } - - #[cfg(target_os = "linux")] - #[test] - fn use_linux_sandbox_bwrap_is_experimental_on_linux() { - assert!(matches!( - Feature::UseLinuxSandboxBwrap.stage(), - Stage::Experimental { .. } - )); - assert_eq!(Feature::UseLinuxSandboxBwrap.default_enabled(), false); - } - - #[cfg(not(target_os = "linux"))] - #[test] - fn use_linux_sandbox_bwrap_is_under_development_off_linux() { - assert_eq!( - Feature::UseLinuxSandboxBwrap.stage(), - Stage::UnderDevelopment - ); - assert_eq!(Feature::UseLinuxSandboxBwrap.default_enabled(), false); - } - - #[test] - fn js_repl_is_experimental_and_user_toggleable() { - let spec = Feature::JsRepl.info(); - let stage = spec.stage; - let expected_node_version = include_str!("../../node-version.txt").trim_end(); - - assert!(matches!(stage, Stage::Experimental { .. })); - assert_eq!(stage.experimental_menu_name(), Some("JavaScript REPL")); - assert_eq!( - stage.experimental_menu_description().map(str::to_owned), - Some(format!( - "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities. Requires Node >= v{expected_node_version} installed." - )) - ); - assert_eq!(Feature::JsRepl.default_enabled(), false); - } - - #[test] - fn guardian_approval_is_experimental_and_user_toggleable() { - let spec = Feature::GuardianApproval.info(); - let stage = spec.stage; - - assert!(matches!(stage, Stage::Experimental { .. })); - assert_eq!( - stage.experimental_menu_name(), - Some("Automatic approval review") - ); - assert_eq!( - stage.experimental_menu_description().map(str::to_owned), - Some( - "Dispatch `on-request` approval prompts (for e.g. sandbox escapes or blocked network access) to a carefully-prompted security reviewer subagent rather than blocking the agent on your input.".to_string() - ) - ); - assert_eq!(stage.experimental_announcement(), None); - assert_eq!(Feature::GuardianApproval.default_enabled(), false); - } - - #[test] - fn request_permissions_is_under_development() { - assert_eq!(Feature::RequestPermissions.stage(), Stage::UnderDevelopment); - assert_eq!(Feature::RequestPermissions.default_enabled(), false); - } - - #[test] - fn request_permissions_tool_is_under_development() { - assert_eq!( - Feature::RequestPermissionsTool.stage(), - Stage::UnderDevelopment - ); - assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false); - } - - #[test] - fn image_generation_is_under_development() { - assert_eq!(Feature::ImageGeneration.stage(), Stage::UnderDevelopment); - assert_eq!(Feature::ImageGeneration.default_enabled(), false); - } - - #[test] - fn collab_is_legacy_alias_for_multi_agent() { - assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab)); - assert_eq!(feature_for_key("collab"), Some(Feature::Collab)); - } - - #[test] - fn spawn_csv_is_under_development() { - assert_eq!(Feature::SpawnCsv.stage(), Stage::UnderDevelopment); - assert_eq!(Feature::SpawnCsv.default_enabled(), false); - } - - #[test] - fn spawn_csv_normalization_enables_multi_agent_one_way() { - let mut spawn_csv_features = Features::with_defaults(); - spawn_csv_features.enable(Feature::SpawnCsv); - spawn_csv_features.normalize_dependencies(); - assert_eq!(spawn_csv_features.enabled(Feature::SpawnCsv), true); - assert_eq!(spawn_csv_features.enabled(Feature::Collab), true); - - let mut collab_features = Features::with_defaults(); - collab_features.enable(Feature::Collab); - collab_features.normalize_dependencies(); - assert_eq!(collab_features.enabled(Feature::Collab), true); - assert_eq!(collab_features.enabled(Feature::SpawnCsv), false); - } - - #[test] - fn apps_require_feature_flag_and_chatgpt_auth() { - let mut features = Features::with_defaults(); - assert!(!features.apps_enabled_for_auth(None)); - - features.enable(Feature::Apps); - assert!(!features.apps_enabled_for_auth(None)); - - let api_key_auth = CodexAuth::from_api_key("test-api-key"); - assert!(!features.apps_enabled_for_auth(Some(&api_key_auth))); - - let chatgpt_auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - assert!(features.apps_enabled_for_auth(Some(&chatgpt_auth))); - } -} +#[path = "features_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/features_tests.rs b/codex-rs/core/src/features_tests.rs new file mode 100644 index 0000000000..aa0d1a6dc8 --- /dev/null +++ b/codex-rs/core/src/features_tests.rs @@ -0,0 +1,169 @@ +use super::*; + +use pretty_assertions::assert_eq; + +#[test] +fn under_development_features_are_disabled_by_default() { + for spec in FEATURES { + if matches!(spec.stage, Stage::UnderDevelopment) { + assert_eq!( + spec.default_enabled, false, + "feature `{}` is under development and must be disabled by default", + spec.key + ); + } + } +} + +#[test] +fn default_enabled_features_are_stable() { + for spec in FEATURES { + if spec.default_enabled { + assert!( + matches!(spec.stage, Stage::Stable | Stage::Removed), + "feature `{}` is enabled by default but is not stable/removed ({:?})", + spec.key, + spec.stage + ); + } + } +} + +#[test] +fn use_legacy_landlock_is_stable_and_disabled_by_default() { + assert_eq!(Feature::UseLegacyLandlock.stage(), Stage::Stable); + assert_eq!(Feature::UseLegacyLandlock.default_enabled(), false); +} + +#[test] +fn use_linux_sandbox_bwrap_is_removed_and_disabled_by_default() { + assert_eq!(Feature::UseLinuxSandboxBwrap.stage(), Stage::Removed); + assert_eq!(Feature::UseLinuxSandboxBwrap.default_enabled(), false); +} + +#[test] +fn js_repl_is_experimental_and_user_toggleable() { + let spec = Feature::JsRepl.info(); + let stage = spec.stage; + let expected_node_version = include_str!("../../node-version.txt").trim_end(); + + assert!(matches!(stage, Stage::Experimental { .. })); + assert_eq!(stage.experimental_menu_name(), Some("JavaScript REPL")); + assert_eq!( + stage.experimental_menu_description().map(str::to_owned), + Some(format!( + "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities. Requires Node >= v{expected_node_version} installed." + )) + ); + assert_eq!(Feature::JsRepl.default_enabled(), false); +} + +#[test] +fn guardian_approval_is_experimental_and_user_toggleable() { + let spec = Feature::GuardianApproval.info(); + let stage = spec.stage; + + assert!(matches!(stage, Stage::Experimental { .. })); + assert_eq!( + stage.experimental_menu_name(), + Some("Automatic approval review") + ); + assert_eq!( + stage.experimental_menu_description().map(str::to_owned), + Some( + "Dispatch `on-request` approval prompts (for e.g. sandbox escapes or blocked network access) to a carefully-prompted security reviewer subagent rather than blocking the agent on your input.".to_string() + ) + ); + assert_eq!(stage.experimental_announcement(), None); + assert_eq!(Feature::GuardianApproval.default_enabled(), false); +} + +#[test] +fn request_permissions_is_under_development() { + assert_eq!(Feature::RequestPermissions.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::RequestPermissions.default_enabled(), false); +} + +#[test] +fn request_permissions_tool_is_under_development() { + assert_eq!( + Feature::RequestPermissionsTool.stage(), + Stage::UnderDevelopment + ); + assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false); +} + +#[test] +fn tool_suggest_is_under_development() { + assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::ToolSuggest.default_enabled(), false); +} + +#[test] +fn use_linux_sandbox_bwrap_is_a_removed_feature_key() { + assert_eq!( + feature_for_key("use_legacy_landlock"), + Some(Feature::UseLegacyLandlock) + ); + assert_eq!( + feature_for_key("use_linux_sandbox_bwrap"), + Some(Feature::UseLinuxSandboxBwrap) + ); +} + +#[test] +fn image_generation_is_under_development() { + assert_eq!(Feature::ImageGeneration.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::ImageGeneration.default_enabled(), false); +} + +#[test] +fn image_detail_original_feature_is_under_development() { + assert_eq!( + Feature::ImageDetailOriginal.stage(), + Stage::UnderDevelopment + ); + assert_eq!(Feature::ImageDetailOriginal.default_enabled(), false); +} + +#[test] +fn collab_is_legacy_alias_for_multi_agent() { + assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab)); + assert_eq!(feature_for_key("collab"), Some(Feature::Collab)); +} + +#[test] +fn spawn_csv_is_under_development() { + assert_eq!(Feature::SpawnCsv.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::SpawnCsv.default_enabled(), false); +} + +#[test] +fn spawn_csv_normalization_enables_multi_agent_one_way() { + let mut spawn_csv_features = Features::with_defaults(); + spawn_csv_features.enable(Feature::SpawnCsv); + spawn_csv_features.normalize_dependencies(); + assert_eq!(spawn_csv_features.enabled(Feature::SpawnCsv), true); + assert_eq!(spawn_csv_features.enabled(Feature::Collab), true); + + let mut collab_features = Features::with_defaults(); + collab_features.enable(Feature::Collab); + collab_features.normalize_dependencies(); + assert_eq!(collab_features.enabled(Feature::Collab), true); + assert_eq!(collab_features.enabled(Feature::SpawnCsv), false); +} + +#[test] +fn apps_require_feature_flag_and_chatgpt_auth() { + let mut features = Features::with_defaults(); + assert!(!features.apps_enabled_for_auth(None)); + + features.enable(Feature::Apps); + assert!(!features.apps_enabled_for_auth(None)); + + let api_key_auth = CodexAuth::from_api_key("test-api-key"); + assert!(!features.apps_enabled_for_auth(Some(&api_key_auth))); + + let chatgpt_auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + assert!(features.apps_enabled_for_auth(Some(&chatgpt_auth))); +} diff --git a/codex-rs/core/src/file_watcher.rs b/codex-rs/core/src/file_watcher.rs index f44a431568..7b2cbd76b0 100644 --- a/codex-rs/core/src/file_watcher.rs +++ b/codex-rs/core/src/file_watcher.rs @@ -350,251 +350,5 @@ fn is_skills_path(path: &Path, roots: &HashSet) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use notify::EventKind; - use notify::event::AccessKind; - use notify::event::AccessMode; - use notify::event::CreateKind; - use notify::event::ModifyKind; - use notify::event::RemoveKind; - use pretty_assertions::assert_eq; - use tokio::time::timeout; - - fn path(name: &str) -> PathBuf { - PathBuf::from(name) - } - - fn notify_event(kind: EventKind, paths: Vec) -> Event { - let mut event = Event::new(kind); - for path in paths { - event = event.add_path(path); - } - event - } - - #[test] - fn throttles_and_coalesces_within_interval() { - let start = Instant::now(); - let mut throttled = ThrottledPaths::new(start); - - throttled.add(vec![path("a")]); - let first = throttled.take_ready(start).expect("first emit"); - assert_eq!(first, vec![path("a")]); - - throttled.add(vec![path("b"), path("c")]); - assert_eq!(throttled.take_ready(start), None); - - let second = throttled - .take_ready(start + WATCHER_THROTTLE_INTERVAL) - .expect("coalesced emit"); - assert_eq!(second, vec![path("b"), path("c")]); - } - - #[test] - fn flushes_pending_on_shutdown() { - let start = Instant::now(); - let mut throttled = ThrottledPaths::new(start); - - throttled.add(vec![path("a")]); - let _ = throttled.take_ready(start).expect("first emit"); - - throttled.add(vec![path("b")]); - assert_eq!(throttled.take_ready(start), None); - - let flushed = throttled - .take_pending(start) - .expect("shutdown flush emits pending paths"); - assert_eq!(flushed, vec![path("b")]); - } - - #[test] - fn classify_event_filters_to_skills_roots() { - let root = path("/tmp/skills"); - let state = RwLock::new(WatchState { - skills_root_ref_counts: HashMap::from([(root.clone(), 1)]), - }); - let event = notify_event( - EventKind::Create(CreateKind::Any), - vec![ - root.join("demo/SKILL.md"), - path("/tmp/other/not-a-skill.txt"), - ], - ); - - let classified = classify_event(&event, &state); - assert_eq!(classified, vec![root.join("demo/SKILL.md")]); - } - - #[test] - fn classify_event_supports_multiple_roots_without_prefix_false_positives() { - let root_a = path("/tmp/skills"); - let root_b = path("/tmp/workspace/.codex/skills"); - let state = RwLock::new(WatchState { - skills_root_ref_counts: HashMap::from([(root_a.clone(), 1), (root_b.clone(), 1)]), - }); - let event = notify_event( - EventKind::Modify(ModifyKind::Any), - vec![ - root_a.join("alpha/SKILL.md"), - path("/tmp/skills-extra/not-under-skills.txt"), - root_b.join("beta/SKILL.md"), - ], - ); - - let classified = classify_event(&event, &state); - assert_eq!( - classified, - vec![root_a.join("alpha/SKILL.md"), root_b.join("beta/SKILL.md")] - ); - } - - #[test] - fn classify_event_ignores_non_mutating_event_kinds() { - let root = path("/tmp/skills"); - let state = RwLock::new(WatchState { - skills_root_ref_counts: HashMap::from([(root.clone(), 1)]), - }); - let path = root.join("demo/SKILL.md"); - - let access_event = notify_event( - EventKind::Access(AccessKind::Open(AccessMode::Any)), - vec![path.clone()], - ); - assert_eq!(classify_event(&access_event, &state), Vec::::new()); - - let any_event = notify_event(EventKind::Any, vec![path.clone()]); - assert_eq!(classify_event(&any_event, &state), Vec::::new()); - - let other_event = notify_event(EventKind::Other, vec![path]); - assert_eq!(classify_event(&other_event, &state), Vec::::new()); - } - - #[test] - fn register_skills_root_dedupes_state_entries() { - let watcher = FileWatcher::noop(); - let root = path("/tmp/skills"); - watcher.register_skills_root(root.clone()); - watcher.register_skills_root(root); - watcher.register_skills_root(path("/tmp/other-skills")); - - let state = watcher.state.read().expect("state lock"); - assert_eq!(state.skills_root_ref_counts.len(), 2); - } - - #[test] - fn watch_registration_drop_unregisters_roots() { - let watcher = Arc::new(FileWatcher::noop()); - let root = path("/tmp/skills"); - watcher.register_skills_root(root.clone()); - let registration = WatchRegistration { - file_watcher: Arc::downgrade(&watcher), - roots: vec![root], - }; - - drop(registration); - - let state = watcher.state.read().expect("state lock"); - assert_eq!(state.skills_root_ref_counts.len(), 0); - } - - #[test] - fn unregister_holds_state_lock_until_unwatch_finishes() { - let temp_dir = tempfile::tempdir().expect("temp dir"); - let root = temp_dir.path().join("skills"); - std::fs::create_dir(&root).expect("create root"); - - let watcher = Arc::new(FileWatcher::new(temp_dir.path().to_path_buf()).expect("watcher")); - watcher.register_skills_root(root.clone()); - - let inner = watcher.inner.as_ref().expect("watcher inner"); - let inner_guard = inner.lock().expect("inner lock"); - - let unregister_watcher = Arc::clone(&watcher); - let unregister_root = root.clone(); - let unregister_thread = std::thread::spawn(move || { - unregister_watcher.unregister_roots(&[unregister_root]); - }); - - let state_lock_observed = (0..100).any(|_| { - let locked = watcher.state.try_write().is_err(); - if !locked { - std::thread::sleep(Duration::from_millis(10)); - } - locked - }); - assert_eq!(state_lock_observed, true); - - let register_watcher = Arc::clone(&watcher); - let register_root = root.clone(); - let register_thread = std::thread::spawn(move || { - register_watcher.register_skills_root(register_root); - }); - - drop(inner_guard); - - unregister_thread.join().expect("unregister join"); - register_thread.join().expect("register join"); - - let state = watcher.state.read().expect("state lock"); - assert_eq!(state.skills_root_ref_counts.get(&root), Some(&1)); - drop(state); - - let inner = watcher.inner.as_ref().expect("watcher inner"); - let inner = inner.lock().expect("inner lock"); - assert_eq!( - inner.watched_paths.get(&root), - Some(&RecursiveMode::Recursive) - ); - } - - #[tokio::test] - async fn spawn_event_loop_flushes_pending_changes_on_shutdown() { - let watcher = FileWatcher::noop(); - let root = path("/tmp/skills"); - { - let mut state = watcher.state.write().expect("state lock"); - state.skills_root_ref_counts.insert(root.clone(), 1); - } - - let (raw_tx, raw_rx) = mpsc::unbounded_channel(); - let (tx, mut rx) = broadcast::channel(8); - watcher.spawn_event_loop(raw_rx, Arc::clone(&watcher.state), tx); - - raw_tx - .send(Ok(notify_event( - EventKind::Create(CreateKind::File), - vec![root.join("a/SKILL.md")], - ))) - .expect("send first event"); - let first = timeout(Duration::from_secs(2), rx.recv()) - .await - .expect("first watcher event") - .expect("broadcast recv first"); - assert_eq!( - first, - FileWatcherEvent::SkillsChanged { - paths: vec![root.join("a/SKILL.md")] - } - ); - - raw_tx - .send(Ok(notify_event( - EventKind::Remove(RemoveKind::File), - vec![root.join("b/SKILL.md")], - ))) - .expect("send second event"); - drop(raw_tx); - - let second = timeout(Duration::from_secs(2), rx.recv()) - .await - .expect("second watcher event") - .expect("broadcast recv second"); - assert_eq!( - second, - FileWatcherEvent::SkillsChanged { - paths: vec![root.join("b/SKILL.md")] - } - ); - } -} +#[path = "file_watcher_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/file_watcher_tests.rs b/codex-rs/core/src/file_watcher_tests.rs new file mode 100644 index 0000000000..995e7f7cea --- /dev/null +++ b/codex-rs/core/src/file_watcher_tests.rs @@ -0,0 +1,246 @@ +use super::*; +use notify::EventKind; +use notify::event::AccessKind; +use notify::event::AccessMode; +use notify::event::CreateKind; +use notify::event::ModifyKind; +use notify::event::RemoveKind; +use pretty_assertions::assert_eq; +use tokio::time::timeout; + +fn path(name: &str) -> PathBuf { + PathBuf::from(name) +} + +fn notify_event(kind: EventKind, paths: Vec) -> Event { + let mut event = Event::new(kind); + for path in paths { + event = event.add_path(path); + } + event +} + +#[test] +fn throttles_and_coalesces_within_interval() { + let start = Instant::now(); + let mut throttled = ThrottledPaths::new(start); + + throttled.add(vec![path("a")]); + let first = throttled.take_ready(start).expect("first emit"); + assert_eq!(first, vec![path("a")]); + + throttled.add(vec![path("b"), path("c")]); + assert_eq!(throttled.take_ready(start), None); + + let second = throttled + .take_ready(start + WATCHER_THROTTLE_INTERVAL) + .expect("coalesced emit"); + assert_eq!(second, vec![path("b"), path("c")]); +} + +#[test] +fn flushes_pending_on_shutdown() { + let start = Instant::now(); + let mut throttled = ThrottledPaths::new(start); + + throttled.add(vec![path("a")]); + let _ = throttled.take_ready(start).expect("first emit"); + + throttled.add(vec![path("b")]); + assert_eq!(throttled.take_ready(start), None); + + let flushed = throttled + .take_pending(start) + .expect("shutdown flush emits pending paths"); + assert_eq!(flushed, vec![path("b")]); +} + +#[test] +fn classify_event_filters_to_skills_roots() { + let root = path("/tmp/skills"); + let state = RwLock::new(WatchState { + skills_root_ref_counts: HashMap::from([(root.clone(), 1)]), + }); + let event = notify_event( + EventKind::Create(CreateKind::Any), + vec![ + root.join("demo/SKILL.md"), + path("/tmp/other/not-a-skill.txt"), + ], + ); + + let classified = classify_event(&event, &state); + assert_eq!(classified, vec![root.join("demo/SKILL.md")]); +} + +#[test] +fn classify_event_supports_multiple_roots_without_prefix_false_positives() { + let root_a = path("/tmp/skills"); + let root_b = path("/tmp/workspace/.codex/skills"); + let state = RwLock::new(WatchState { + skills_root_ref_counts: HashMap::from([(root_a.clone(), 1), (root_b.clone(), 1)]), + }); + let event = notify_event( + EventKind::Modify(ModifyKind::Any), + vec![ + root_a.join("alpha/SKILL.md"), + path("/tmp/skills-extra/not-under-skills.txt"), + root_b.join("beta/SKILL.md"), + ], + ); + + let classified = classify_event(&event, &state); + assert_eq!( + classified, + vec![root_a.join("alpha/SKILL.md"), root_b.join("beta/SKILL.md")] + ); +} + +#[test] +fn classify_event_ignores_non_mutating_event_kinds() { + let root = path("/tmp/skills"); + let state = RwLock::new(WatchState { + skills_root_ref_counts: HashMap::from([(root.clone(), 1)]), + }); + let path = root.join("demo/SKILL.md"); + + let access_event = notify_event( + EventKind::Access(AccessKind::Open(AccessMode::Any)), + vec![path.clone()], + ); + assert_eq!(classify_event(&access_event, &state), Vec::::new()); + + let any_event = notify_event(EventKind::Any, vec![path.clone()]); + assert_eq!(classify_event(&any_event, &state), Vec::::new()); + + let other_event = notify_event(EventKind::Other, vec![path]); + assert_eq!(classify_event(&other_event, &state), Vec::::new()); +} + +#[test] +fn register_skills_root_dedupes_state_entries() { + let watcher = FileWatcher::noop(); + let root = path("/tmp/skills"); + watcher.register_skills_root(root.clone()); + watcher.register_skills_root(root); + watcher.register_skills_root(path("/tmp/other-skills")); + + let state = watcher.state.read().expect("state lock"); + assert_eq!(state.skills_root_ref_counts.len(), 2); +} + +#[test] +fn watch_registration_drop_unregisters_roots() { + let watcher = Arc::new(FileWatcher::noop()); + let root = path("/tmp/skills"); + watcher.register_skills_root(root.clone()); + let registration = WatchRegistration { + file_watcher: Arc::downgrade(&watcher), + roots: vec![root], + }; + + drop(registration); + + let state = watcher.state.read().expect("state lock"); + assert_eq!(state.skills_root_ref_counts.len(), 0); +} + +#[test] +fn unregister_holds_state_lock_until_unwatch_finishes() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let root = temp_dir.path().join("skills"); + std::fs::create_dir(&root).expect("create root"); + + let watcher = Arc::new(FileWatcher::new(temp_dir.path().to_path_buf()).expect("watcher")); + watcher.register_skills_root(root.clone()); + + let inner = watcher.inner.as_ref().expect("watcher inner"); + let inner_guard = inner.lock().expect("inner lock"); + + let unregister_watcher = Arc::clone(&watcher); + let unregister_root = root.clone(); + let unregister_thread = std::thread::spawn(move || { + unregister_watcher.unregister_roots(&[unregister_root]); + }); + + let state_lock_observed = (0..100).any(|_| { + let locked = watcher.state.try_write().is_err(); + if !locked { + std::thread::sleep(Duration::from_millis(10)); + } + locked + }); + assert_eq!(state_lock_observed, true); + + let register_watcher = Arc::clone(&watcher); + let register_root = root.clone(); + let register_thread = std::thread::spawn(move || { + register_watcher.register_skills_root(register_root); + }); + + drop(inner_guard); + + unregister_thread.join().expect("unregister join"); + register_thread.join().expect("register join"); + + let state = watcher.state.read().expect("state lock"); + assert_eq!(state.skills_root_ref_counts.get(&root), Some(&1)); + drop(state); + + let inner = watcher.inner.as_ref().expect("watcher inner"); + let inner = inner.lock().expect("inner lock"); + assert_eq!( + inner.watched_paths.get(&root), + Some(&RecursiveMode::Recursive) + ); +} + +#[tokio::test] +async fn spawn_event_loop_flushes_pending_changes_on_shutdown() { + let watcher = FileWatcher::noop(); + let root = path("/tmp/skills"); + { + let mut state = watcher.state.write().expect("state lock"); + state.skills_root_ref_counts.insert(root.clone(), 1); + } + + let (raw_tx, raw_rx) = mpsc::unbounded_channel(); + let (tx, mut rx) = broadcast::channel(8); + watcher.spawn_event_loop(raw_rx, Arc::clone(&watcher.state), tx); + + raw_tx + .send(Ok(notify_event( + EventKind::Create(CreateKind::File), + vec![root.join("a/SKILL.md")], + ))) + .expect("send first event"); + let first = timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("first watcher event") + .expect("broadcast recv first"); + assert_eq!( + first, + FileWatcherEvent::SkillsChanged { + paths: vec![root.join("a/SKILL.md")] + } + ); + + raw_tx + .send(Ok(notify_event( + EventKind::Remove(RemoveKind::File), + vec![root.join("b/SKILL.md")], + ))) + .expect("send second event"); + drop(raw_tx); + + let second = timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("second watcher event") + .expect("broadcast recv second"); + assert_eq!( + second, + FileWatcherEvent::SkillsChanged { + paths: vec![root.join("b/SKILL.md")] + } + ); +} diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index 676d230c20..052f786bfa 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -691,597 +691,5 @@ pub async fn current_branch_name(cwd: &Path) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - use core_test_support::skip_if_sandbox; - use std::fs; - use std::path::PathBuf; - use tempfile::TempDir; - - // Helper function to create a test git repository - async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf { - let repo_path = temp_dir.path().join("repo"); - fs::create_dir(&repo_path).expect("Failed to create repo dir"); - let envs = vec![ - ("GIT_CONFIG_GLOBAL", "/dev/null"), - ("GIT_CONFIG_NOSYSTEM", "1"), - ]; - - // Initialize git repo - Command::new("git") - .envs(envs.clone()) - .args(["init"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to init git repo"); - - // Configure git user (required for commits) - Command::new("git") - .envs(envs.clone()) - .args(["config", "user.name", "Test User"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to set git user name"); - - Command::new("git") - .envs(envs.clone()) - .args(["config", "user.email", "test@example.com"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to set git user email"); - - // Create a test file and commit it - let test_file = repo_path.join("test.txt"); - fs::write(&test_file, "test content").expect("Failed to write test file"); - - Command::new("git") - .envs(envs.clone()) - .args(["add", "."]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to add files"); - - Command::new("git") - .envs(envs.clone()) - .args(["commit", "-m", "Initial commit"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to commit"); - - repo_path - } - - #[tokio::test] - async fn test_recent_commits_non_git_directory_returns_empty() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let entries = recent_commits(temp_dir.path(), 10).await; - assert!(entries.is_empty(), "expected no commits outside a git repo"); - } - - #[tokio::test] - async fn test_recent_commits_orders_and_limits() { - skip_if_sandbox!(); - use tokio::time::Duration; - use tokio::time::sleep; - - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - - // Make three distinct commits with small delays to ensure ordering by timestamp. - fs::write(repo_path.join("file.txt"), "one").unwrap(); - Command::new("git") - .args(["add", "file.txt"]) - .current_dir(&repo_path) - .output() - .await - .expect("git add"); - Command::new("git") - .args(["commit", "-m", "first change"]) - .current_dir(&repo_path) - .output() - .await - .expect("git commit 1"); - - sleep(Duration::from_millis(1100)).await; - - fs::write(repo_path.join("file.txt"), "two").unwrap(); - Command::new("git") - .args(["add", "file.txt"]) - .current_dir(&repo_path) - .output() - .await - .expect("git add 2"); - Command::new("git") - .args(["commit", "-m", "second change"]) - .current_dir(&repo_path) - .output() - .await - .expect("git commit 2"); - - sleep(Duration::from_millis(1100)).await; - - fs::write(repo_path.join("file.txt"), "three").unwrap(); - Command::new("git") - .args(["add", "file.txt"]) - .current_dir(&repo_path) - .output() - .await - .expect("git add 3"); - Command::new("git") - .args(["commit", "-m", "third change"]) - .current_dir(&repo_path) - .output() - .await - .expect("git commit 3"); - - // Request the latest 3 commits; should be our three changes in reverse time order. - let entries = recent_commits(&repo_path, 3).await; - assert_eq!(entries.len(), 3); - assert_eq!(entries[0].subject, "third change"); - assert_eq!(entries[1].subject, "second change"); - assert_eq!(entries[2].subject, "first change"); - // Basic sanity on SHA formatting - for e in entries { - assert!(e.sha.len() >= 7 && e.sha.chars().all(|c| c.is_ascii_hexdigit())); - } - } - - async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) { - let repo_path = create_test_git_repo(temp_dir).await; - let remote_path = temp_dir.path().join("remote.git"); - - Command::new("git") - .args(["init", "--bare", remote_path.to_str().unwrap()]) - .output() - .await - .expect("Failed to init bare remote"); - - Command::new("git") - .args(["remote", "add", "origin", remote_path.to_str().unwrap()]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to add remote"); - - let output = Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to get branch"); - let branch = String::from_utf8(output.stdout).unwrap().trim().to_string(); - - Command::new("git") - .args(["push", "-u", "origin", &branch]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to push initial commit"); - - (repo_path, branch) - } - - #[tokio::test] - async fn test_collect_git_info_non_git_directory() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let result = collect_git_info(temp_dir.path()).await; - assert!(result.is_none()); - } - - #[tokio::test] - async fn test_collect_git_info_git_repository() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - - let git_info = collect_git_info(&repo_path) - .await - .expect("Should collect git info from repo"); - - // Should have commit hash - assert!(git_info.commit_hash.is_some()); - let commit_hash = git_info.commit_hash.unwrap(); - assert_eq!(commit_hash.len(), 40); // SHA-1 hash should be 40 characters - assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit())); - - // Should have branch (likely "main" or "master") - assert!(git_info.branch.is_some()); - let branch = git_info.branch.unwrap(); - assert!(branch == "main" || branch == "master"); - - // Repository URL might be None for local repos without remote - // This is acceptable behavior - } - - #[tokio::test] - async fn test_collect_git_info_with_remote() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - - // Add a remote origin - Command::new("git") - .args([ - "remote", - "add", - "origin", - "https://github.com/example/repo.git", - ]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to add remote"); - - let git_info = collect_git_info(&repo_path) - .await - .expect("Should collect git info from repo"); - - let remote_url_output = Command::new("git") - .args(["remote", "get-url", "origin"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to read remote url"); - // Some dev environments rewrite remotes (e.g., force SSH), so compare against - // whatever URL Git reports instead of a fixed placeholder. - let expected_remote = String::from_utf8(remote_url_output.stdout) - .unwrap() - .trim() - .to_string(); - - // Should have repository URL - assert_eq!(git_info.repository_url, Some(expected_remote)); - } - - #[tokio::test] - async fn test_collect_git_info_detached_head() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - - // Get the current commit hash - let output = Command::new("git") - .args(["rev-parse", "HEAD"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to get HEAD"); - let commit_hash = String::from_utf8(output.stdout).unwrap().trim().to_string(); - - // Checkout the commit directly (detached HEAD) - Command::new("git") - .args(["checkout", &commit_hash]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to checkout commit"); - - let git_info = collect_git_info(&repo_path) - .await - .expect("Should collect git info from repo"); - - // Should have commit hash - assert!(git_info.commit_hash.is_some()); - // Branch should be None for detached HEAD (since rev-parse --abbrev-ref HEAD returns "HEAD") - assert!(git_info.branch.is_none()); - } - - #[tokio::test] - async fn test_collect_git_info_with_branch() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - - // Create and checkout a new branch - Command::new("git") - .args(["checkout", "-b", "feature-branch"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to create branch"); - - let git_info = collect_git_info(&repo_path) - .await - .expect("Should collect git info from repo"); - - // Should have the new branch name - assert_eq!(git_info.branch, Some("feature-branch".to_string())); - } - - #[tokio::test] - async fn test_get_has_changes_non_git_directory_returns_none() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - assert_eq!(get_has_changes(temp_dir.path()).await, None); - } - - #[tokio::test] - async fn test_get_has_changes_clean_repo_returns_false() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - assert_eq!(get_has_changes(&repo_path).await, Some(false)); - } - - #[tokio::test] - async fn test_get_has_changes_with_tracked_change_returns_true() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - - fs::write(repo_path.join("test.txt"), "updated tracked file").expect("write tracked file"); - assert_eq!(get_has_changes(&repo_path).await, Some(true)); - } - - #[tokio::test] - async fn test_get_has_changes_with_untracked_change_returns_true() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - - fs::write(repo_path.join("new_file.txt"), "untracked").expect("write untracked file"); - assert_eq!(get_has_changes(&repo_path).await, Some(true)); - } - - #[tokio::test] - async fn test_get_git_working_tree_state_clean_repo() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await; - - let remote_sha = Command::new("git") - .args(["rev-parse", &format!("origin/{branch}")]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to rev-parse remote"); - let remote_sha = String::from_utf8(remote_sha.stdout) - .unwrap() - .trim() - .to_string(); - - let state = git_diff_to_remote(&repo_path) - .await - .expect("Should collect working tree state"); - assert_eq!(state.sha, GitSha::new(&remote_sha)); - assert!(state.diff.is_empty()); - } - - #[tokio::test] - async fn test_get_git_working_tree_state_with_changes() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await; - - let tracked = repo_path.join("test.txt"); - fs::write(&tracked, "modified").unwrap(); - fs::write(repo_path.join("untracked.txt"), "new").unwrap(); - - let remote_sha = Command::new("git") - .args(["rev-parse", &format!("origin/{branch}")]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to rev-parse remote"); - let remote_sha = String::from_utf8(remote_sha.stdout) - .unwrap() - .trim() - .to_string(); - - let state = git_diff_to_remote(&repo_path) - .await - .expect("Should collect working tree state"); - assert_eq!(state.sha, GitSha::new(&remote_sha)); - assert!(state.diff.contains("test.txt")); - assert!(state.diff.contains("untracked.txt")); - } - - #[tokio::test] - async fn test_get_git_working_tree_state_branch_fallback() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let (repo_path, _branch) = create_test_git_repo_with_remote(&temp_dir).await; - - Command::new("git") - .args(["checkout", "-b", "feature"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to create feature branch"); - Command::new("git") - .args(["push", "-u", "origin", "feature"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to push feature branch"); - - Command::new("git") - .args(["checkout", "-b", "local-branch"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to create local branch"); - - let remote_sha = Command::new("git") - .args(["rev-parse", "origin/feature"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to rev-parse remote"); - let remote_sha = String::from_utf8(remote_sha.stdout) - .unwrap() - .trim() - .to_string(); - - let state = git_diff_to_remote(&repo_path) - .await - .expect("Should collect working tree state"); - assert_eq!(state.sha, GitSha::new(&remote_sha)); - } - - #[test] - fn resolve_root_git_project_for_trust_returns_none_outside_repo() { - let tmp = TempDir::new().expect("tempdir"); - assert!(resolve_root_git_project_for_trust(tmp.path()).is_none()); - } - - #[tokio::test] - async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - let expected = std::fs::canonicalize(&repo_path).unwrap(); - - assert_eq!( - resolve_root_git_project_for_trust(&repo_path), - Some(expected.clone()) - ); - let nested = repo_path.join("sub/dir"); - std::fs::create_dir_all(&nested).unwrap(); - assert_eq!(resolve_root_git_project_for_trust(&nested), Some(expected)); - } - - #[tokio::test] - async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_root() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - - // Create a linked worktree - let wt_root = temp_dir.path().join("wt"); - let _ = std::process::Command::new("git") - .args([ - "worktree", - "add", - wt_root.to_str().unwrap(), - "-b", - "feature/x", - ]) - .current_dir(&repo_path) - .output() - .expect("git worktree add"); - - let expected = std::fs::canonicalize(&repo_path).ok(); - let got = resolve_root_git_project_for_trust(&wt_root) - .and_then(|p| std::fs::canonicalize(p).ok()); - assert_eq!(got, expected); - let nested = wt_root.join("nested/sub"); - std::fs::create_dir_all(&nested).unwrap(); - let got_nested = - resolve_root_git_project_for_trust(&nested).and_then(|p| std::fs::canonicalize(p).ok()); - assert_eq!(got_nested, expected); - } - - #[test] - fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() { - let tmp = TempDir::new().expect("tempdir"); - let repo_root = tmp.path().join("repo"); - let common_dir = repo_root.join(".git"); - let worktree_git_dir = common_dir.join("worktrees").join("feature-x"); - let worktree_root = tmp.path().join("wt"); - std::fs::create_dir_all(&worktree_git_dir).unwrap(); - std::fs::create_dir_all(&worktree_root).unwrap(); - std::fs::create_dir_all(worktree_root.join("nested")).unwrap(); - std::fs::write( - worktree_root.join(".git"), - format!("gitdir: {}\n", worktree_git_dir.display()), - ) - .unwrap(); - - let expected = std::fs::canonicalize(&repo_root).unwrap(); - assert_eq!( - resolve_root_git_project_for_trust(&worktree_root), - Some(expected.clone()) - ); - assert_eq!( - resolve_root_git_project_for_trust(&worktree_root.join("nested")), - Some(expected) - ); - } - - #[test] - fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() { - let tmp = TempDir::new().expect("tempdir"); - let proj = tmp.path().join("proj"); - std::fs::create_dir_all(proj.join("nested")).unwrap(); - - // `.git` is a file but does not point to a worktrees path - std::fs::write( - proj.join(".git"), - format!( - "gitdir: {}\n", - tmp.path().join("some/other/location").display() - ), - ) - .unwrap(); - - assert!(resolve_root_git_project_for_trust(&proj).is_none()); - assert!(resolve_root_git_project_for_trust(&proj.join("nested")).is_none()); - } - - #[tokio::test] - async fn test_get_git_working_tree_state_unpushed_commit() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await; - - let remote_sha = Command::new("git") - .args(["rev-parse", &format!("origin/{branch}")]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to rev-parse remote"); - let remote_sha = String::from_utf8(remote_sha.stdout) - .unwrap() - .trim() - .to_string(); - - fs::write(repo_path.join("test.txt"), "updated").unwrap(); - Command::new("git") - .args(["add", "test.txt"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to add file"); - Command::new("git") - .args(["commit", "-m", "local change"]) - .current_dir(&repo_path) - .output() - .await - .expect("Failed to commit"); - - let state = git_diff_to_remote(&repo_path) - .await - .expect("Should collect working tree state"); - assert_eq!(state.sha, GitSha::new(&remote_sha)); - assert!(state.diff.contains("updated")); - } - - #[test] - fn test_git_info_serialization() { - let git_info = GitInfo { - commit_hash: Some("abc123def456".to_string()), - branch: Some("main".to_string()), - repository_url: Some("https://github.com/example/repo.git".to_string()), - }; - - let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo"); - let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON"); - - assert_eq!(parsed["commit_hash"], "abc123def456"); - assert_eq!(parsed["branch"], "main"); - assert_eq!( - parsed["repository_url"], - "https://github.com/example/repo.git" - ); - } - - #[test] - fn test_git_info_serialization_with_nones() { - let git_info = GitInfo { - commit_hash: None, - branch: None, - repository_url: None, - }; - - let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo"); - let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON"); - - // Fields with None values should be omitted due to skip_serializing_if - assert!(!parsed.as_object().unwrap().contains_key("commit_hash")); - assert!(!parsed.as_object().unwrap().contains_key("branch")); - assert!(!parsed.as_object().unwrap().contains_key("repository_url")); - } -} +#[path = "git_info_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/git_info_tests.rs b/codex-rs/core/src/git_info_tests.rs new file mode 100644 index 0000000000..73714ce42f --- /dev/null +++ b/codex-rs/core/src/git_info_tests.rs @@ -0,0 +1,592 @@ +use super::*; + +use core_test_support::skip_if_sandbox; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +// Helper function to create a test git repository +async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf { + let repo_path = temp_dir.path().join("repo"); + fs::create_dir(&repo_path).expect("Failed to create repo dir"); + let envs = vec![ + ("GIT_CONFIG_GLOBAL", "/dev/null"), + ("GIT_CONFIG_NOSYSTEM", "1"), + ]; + + // Initialize git repo + Command::new("git") + .envs(envs.clone()) + .args(["init"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to init git repo"); + + // Configure git user (required for commits) + Command::new("git") + .envs(envs.clone()) + .args(["config", "user.name", "Test User"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to set git user name"); + + Command::new("git") + .envs(envs.clone()) + .args(["config", "user.email", "test@example.com"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to set git user email"); + + // Create a test file and commit it + let test_file = repo_path.join("test.txt"); + fs::write(&test_file, "test content").expect("Failed to write test file"); + + Command::new("git") + .envs(envs.clone()) + .args(["add", "."]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to add files"); + + Command::new("git") + .envs(envs.clone()) + .args(["commit", "-m", "Initial commit"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to commit"); + + repo_path +} + +#[tokio::test] +async fn test_recent_commits_non_git_directory_returns_empty() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let entries = recent_commits(temp_dir.path(), 10).await; + assert!(entries.is_empty(), "expected no commits outside a git repo"); +} + +#[tokio::test] +async fn test_recent_commits_orders_and_limits() { + skip_if_sandbox!(); + use tokio::time::Duration; + use tokio::time::sleep; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + // Make three distinct commits with small delays to ensure ordering by timestamp. + fs::write(repo_path.join("file.txt"), "one").unwrap(); + Command::new("git") + .args(["add", "file.txt"]) + .current_dir(&repo_path) + .output() + .await + .expect("git add"); + Command::new("git") + .args(["commit", "-m", "first change"]) + .current_dir(&repo_path) + .output() + .await + .expect("git commit 1"); + + sleep(Duration::from_millis(1100)).await; + + fs::write(repo_path.join("file.txt"), "two").unwrap(); + Command::new("git") + .args(["add", "file.txt"]) + .current_dir(&repo_path) + .output() + .await + .expect("git add 2"); + Command::new("git") + .args(["commit", "-m", "second change"]) + .current_dir(&repo_path) + .output() + .await + .expect("git commit 2"); + + sleep(Duration::from_millis(1100)).await; + + fs::write(repo_path.join("file.txt"), "three").unwrap(); + Command::new("git") + .args(["add", "file.txt"]) + .current_dir(&repo_path) + .output() + .await + .expect("git add 3"); + Command::new("git") + .args(["commit", "-m", "third change"]) + .current_dir(&repo_path) + .output() + .await + .expect("git commit 3"); + + // Request the latest 3 commits; should be our three changes in reverse time order. + let entries = recent_commits(&repo_path, 3).await; + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].subject, "third change"); + assert_eq!(entries[1].subject, "second change"); + assert_eq!(entries[2].subject, "first change"); + // Basic sanity on SHA formatting + for e in entries { + assert!(e.sha.len() >= 7 && e.sha.chars().all(|c| c.is_ascii_hexdigit())); + } +} + +async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) { + let repo_path = create_test_git_repo(temp_dir).await; + let remote_path = temp_dir.path().join("remote.git"); + + Command::new("git") + .args(["init", "--bare", remote_path.to_str().unwrap()]) + .output() + .await + .expect("Failed to init bare remote"); + + Command::new("git") + .args(["remote", "add", "origin", remote_path.to_str().unwrap()]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to add remote"); + + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to get branch"); + let branch = String::from_utf8(output.stdout).unwrap().trim().to_string(); + + Command::new("git") + .args(["push", "-u", "origin", &branch]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to push initial commit"); + + (repo_path, branch) +} + +#[tokio::test] +async fn test_collect_git_info_non_git_directory() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let result = collect_git_info(temp_dir.path()).await; + assert!(result.is_none()); +} + +#[tokio::test] +async fn test_collect_git_info_git_repository() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + let git_info = collect_git_info(&repo_path) + .await + .expect("Should collect git info from repo"); + + // Should have commit hash + assert!(git_info.commit_hash.is_some()); + let commit_hash = git_info.commit_hash.unwrap(); + assert_eq!(commit_hash.len(), 40); // SHA-1 hash should be 40 characters + assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit())); + + // Should have branch (likely "main" or "master") + assert!(git_info.branch.is_some()); + let branch = git_info.branch.unwrap(); + assert!(branch == "main" || branch == "master"); + + // Repository URL might be None for local repos without remote + // This is acceptable behavior +} + +#[tokio::test] +async fn test_collect_git_info_with_remote() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + // Add a remote origin + Command::new("git") + .args([ + "remote", + "add", + "origin", + "https://github.com/example/repo.git", + ]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to add remote"); + + let git_info = collect_git_info(&repo_path) + .await + .expect("Should collect git info from repo"); + + let remote_url_output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to read remote url"); + // Some dev environments rewrite remotes (e.g., force SSH), so compare against + // whatever URL Git reports instead of a fixed placeholder. + let expected_remote = String::from_utf8(remote_url_output.stdout) + .unwrap() + .trim() + .to_string(); + + // Should have repository URL + assert_eq!(git_info.repository_url, Some(expected_remote)); +} + +#[tokio::test] +async fn test_collect_git_info_detached_head() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + // Get the current commit hash + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to get HEAD"); + let commit_hash = String::from_utf8(output.stdout).unwrap().trim().to_string(); + + // Checkout the commit directly (detached HEAD) + Command::new("git") + .args(["checkout", &commit_hash]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to checkout commit"); + + let git_info = collect_git_info(&repo_path) + .await + .expect("Should collect git info from repo"); + + // Should have commit hash + assert!(git_info.commit_hash.is_some()); + // Branch should be None for detached HEAD (since rev-parse --abbrev-ref HEAD returns "HEAD") + assert!(git_info.branch.is_none()); +} + +#[tokio::test] +async fn test_collect_git_info_with_branch() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + // Create and checkout a new branch + Command::new("git") + .args(["checkout", "-b", "feature-branch"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to create branch"); + + let git_info = collect_git_info(&repo_path) + .await + .expect("Should collect git info from repo"); + + // Should have the new branch name + assert_eq!(git_info.branch, Some("feature-branch".to_string())); +} + +#[tokio::test] +async fn test_get_has_changes_non_git_directory_returns_none() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + assert_eq!(get_has_changes(temp_dir.path()).await, None); +} + +#[tokio::test] +async fn test_get_has_changes_clean_repo_returns_false() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + assert_eq!(get_has_changes(&repo_path).await, Some(false)); +} + +#[tokio::test] +async fn test_get_has_changes_with_tracked_change_returns_true() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + fs::write(repo_path.join("test.txt"), "updated tracked file").expect("write tracked file"); + assert_eq!(get_has_changes(&repo_path).await, Some(true)); +} + +#[tokio::test] +async fn test_get_has_changes_with_untracked_change_returns_true() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + fs::write(repo_path.join("new_file.txt"), "untracked").expect("write untracked file"); + assert_eq!(get_has_changes(&repo_path).await, Some(true)); +} + +#[tokio::test] +async fn test_get_git_working_tree_state_clean_repo() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await; + + let remote_sha = Command::new("git") + .args(["rev-parse", &format!("origin/{branch}")]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to rev-parse remote"); + let remote_sha = String::from_utf8(remote_sha.stdout) + .unwrap() + .trim() + .to_string(); + + let state = git_diff_to_remote(&repo_path) + .await + .expect("Should collect working tree state"); + assert_eq!(state.sha, GitSha::new(&remote_sha)); + assert!(state.diff.is_empty()); +} + +#[tokio::test] +async fn test_get_git_working_tree_state_with_changes() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await; + + let tracked = repo_path.join("test.txt"); + fs::write(&tracked, "modified").unwrap(); + fs::write(repo_path.join("untracked.txt"), "new").unwrap(); + + let remote_sha = Command::new("git") + .args(["rev-parse", &format!("origin/{branch}")]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to rev-parse remote"); + let remote_sha = String::from_utf8(remote_sha.stdout) + .unwrap() + .trim() + .to_string(); + + let state = git_diff_to_remote(&repo_path) + .await + .expect("Should collect working tree state"); + assert_eq!(state.sha, GitSha::new(&remote_sha)); + assert!(state.diff.contains("test.txt")); + assert!(state.diff.contains("untracked.txt")); +} + +#[tokio::test] +async fn test_get_git_working_tree_state_branch_fallback() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let (repo_path, _branch) = create_test_git_repo_with_remote(&temp_dir).await; + + Command::new("git") + .args(["checkout", "-b", "feature"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to create feature branch"); + Command::new("git") + .args(["push", "-u", "origin", "feature"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to push feature branch"); + + Command::new("git") + .args(["checkout", "-b", "local-branch"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to create local branch"); + + let remote_sha = Command::new("git") + .args(["rev-parse", "origin/feature"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to rev-parse remote"); + let remote_sha = String::from_utf8(remote_sha.stdout) + .unwrap() + .trim() + .to_string(); + + let state = git_diff_to_remote(&repo_path) + .await + .expect("Should collect working tree state"); + assert_eq!(state.sha, GitSha::new(&remote_sha)); +} + +#[test] +fn resolve_root_git_project_for_trust_returns_none_outside_repo() { + let tmp = TempDir::new().expect("tempdir"); + assert!(resolve_root_git_project_for_trust(tmp.path()).is_none()); +} + +#[tokio::test] +async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + let expected = std::fs::canonicalize(&repo_path).unwrap(); + + assert_eq!( + resolve_root_git_project_for_trust(&repo_path), + Some(expected.clone()) + ); + let nested = repo_path.join("sub/dir"); + std::fs::create_dir_all(&nested).unwrap(); + assert_eq!(resolve_root_git_project_for_trust(&nested), Some(expected)); +} + +#[tokio::test] +async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_root() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + // Create a linked worktree + let wt_root = temp_dir.path().join("wt"); + let _ = std::process::Command::new("git") + .args([ + "worktree", + "add", + wt_root.to_str().unwrap(), + "-b", + "feature/x", + ]) + .current_dir(&repo_path) + .output() + .expect("git worktree add"); + + let expected = std::fs::canonicalize(&repo_path).ok(); + let got = + resolve_root_git_project_for_trust(&wt_root).and_then(|p| std::fs::canonicalize(p).ok()); + assert_eq!(got, expected); + let nested = wt_root.join("nested/sub"); + std::fs::create_dir_all(&nested).unwrap(); + let got_nested = + resolve_root_git_project_for_trust(&nested).and_then(|p| std::fs::canonicalize(p).ok()); + assert_eq!(got_nested, expected); +} + +#[test] +fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() { + let tmp = TempDir::new().expect("tempdir"); + let repo_root = tmp.path().join("repo"); + let common_dir = repo_root.join(".git"); + let worktree_git_dir = common_dir.join("worktrees").join("feature-x"); + let worktree_root = tmp.path().join("wt"); + std::fs::create_dir_all(&worktree_git_dir).unwrap(); + std::fs::create_dir_all(&worktree_root).unwrap(); + std::fs::create_dir_all(worktree_root.join("nested")).unwrap(); + std::fs::write( + worktree_root.join(".git"), + format!("gitdir: {}\n", worktree_git_dir.display()), + ) + .unwrap(); + + let expected = std::fs::canonicalize(&repo_root).unwrap(); + assert_eq!( + resolve_root_git_project_for_trust(&worktree_root), + Some(expected.clone()) + ); + assert_eq!( + resolve_root_git_project_for_trust(&worktree_root.join("nested")), + Some(expected) + ); +} + +#[test] +fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() { + let tmp = TempDir::new().expect("tempdir"); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(proj.join("nested")).unwrap(); + + // `.git` is a file but does not point to a worktrees path + std::fs::write( + proj.join(".git"), + format!( + "gitdir: {}\n", + tmp.path().join("some/other/location").display() + ), + ) + .unwrap(); + + assert!(resolve_root_git_project_for_trust(&proj).is_none()); + assert!(resolve_root_git_project_for_trust(&proj.join("nested")).is_none()); +} + +#[tokio::test] +async fn test_get_git_working_tree_state_unpushed_commit() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await; + + let remote_sha = Command::new("git") + .args(["rev-parse", &format!("origin/{branch}")]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to rev-parse remote"); + let remote_sha = String::from_utf8(remote_sha.stdout) + .unwrap() + .trim() + .to_string(); + + fs::write(repo_path.join("test.txt"), "updated").unwrap(); + Command::new("git") + .args(["add", "test.txt"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to add file"); + Command::new("git") + .args(["commit", "-m", "local change"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to commit"); + + let state = git_diff_to_remote(&repo_path) + .await + .expect("Should collect working tree state"); + assert_eq!(state.sha, GitSha::new(&remote_sha)); + assert!(state.diff.contains("updated")); +} + +#[test] +fn test_git_info_serialization() { + let git_info = GitInfo { + commit_hash: Some("abc123def456".to_string()), + branch: Some("main".to_string()), + repository_url: Some("https://github.com/example/repo.git".to_string()), + }; + + let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON"); + + assert_eq!(parsed["commit_hash"], "abc123def456"); + assert_eq!(parsed["branch"], "main"); + assert_eq!( + parsed["repository_url"], + "https://github.com/example/repo.git" + ); +} + +#[test] +fn test_git_info_serialization_with_nones() { + let git_info = GitInfo { + commit_hash: None, + branch: None, + repository_url: None, + }; + + let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON"); + + // Fields with None values should be omitted due to skip_serializing_if + assert!(!parsed.as_object().unwrap().contains_key("commit_hash")); + assert!(!parsed.as_object().unwrap().contains_key("branch")); + assert!(!parsed.as_object().unwrap().contains_key("repository_url")); +} diff --git a/codex-rs/core/src/guardian_tests.rs b/codex-rs/core/src/guardian_tests.rs index 031f363295..a387770b71 100644 --- a/codex-rs/core/src/guardian_tests.rs +++ b/codex-rs/core/src/guardian_tests.rs @@ -108,6 +108,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { ResponseItem::FunctionCall { id: None, name: "read_file".to_string(), + namespace: None, arguments: "{\"path\":\"README.md\"}".to_string(), call_id: "call-1".to_string(), }, @@ -324,6 +325,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() ResponseItem::FunctionCall { id: None, name: "gh_repo_view".to_string(), + namespace: None, arguments: "{\"repo\":\"openai/codex\"}".to_string(), call_id: "call-1".to_string(), }, diff --git a/codex-rs/core/src/instructions/user_instructions.rs b/codex-rs/core/src/instructions/user_instructions.rs index 09e6e4c2f7..a0389c9ff8 100644 --- a/codex-rs/core/src/instructions/user_instructions.rs +++ b/codex-rs/core/src/instructions/user_instructions.rs @@ -53,73 +53,5 @@ impl From for ResponseItem { } #[cfg(test)] -mod tests { - use super::*; - use codex_protocol::models::ContentItem; - use pretty_assertions::assert_eq; - - #[test] - fn test_user_instructions() { - let user_instructions = UserInstructions { - directory: "test_directory".to_string(), - text: "test_text".to_string(), - }; - let response_item: ResponseItem = user_instructions.into(); - - let ResponseItem::Message { role, content, .. } = response_item else { - panic!("expected ResponseItem::Message"); - }; - - assert_eq!(role, "user"); - - let [ContentItem::InputText { text }] = content.as_slice() else { - panic!("expected one InputText content item"); - }; - - assert_eq!( - text, - "# AGENTS.md instructions for test_directory\n\n\ntest_text\n", - ); - } - - #[test] - fn test_is_user_instructions() { - assert!(AGENTS_MD_FRAGMENT.matches_text( - "# AGENTS.md instructions for test_directory\n\n\ntest_text\n" - )); - assert!(!AGENTS_MD_FRAGMENT.matches_text("test_text")); - } - - #[test] - fn test_skill_instructions() { - let skill_instructions = SkillInstructions { - name: "demo-skill".to_string(), - path: "skills/demo/SKILL.md".to_string(), - contents: "body".to_string(), - }; - let response_item: ResponseItem = skill_instructions.into(); - - let ResponseItem::Message { role, content, .. } = response_item else { - panic!("expected ResponseItem::Message"); - }; - - assert_eq!(role, "user"); - - let [ContentItem::InputText { text }] = content.as_slice() else { - panic!("expected one InputText content item"); - }; - - assert_eq!( - text, - "\ndemo-skill\nskills/demo/SKILL.md\nbody\n", - ); - } - - #[test] - fn test_is_skill_instructions() { - assert!(SKILL_FRAGMENT.matches_text( - "\ndemo-skill\nskills/demo/SKILL.md\nbody\n" - )); - assert!(!SKILL_FRAGMENT.matches_text("regular text")); - } -} +#[path = "user_instructions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/instructions/user_instructions_tests.rs b/codex-rs/core/src/instructions/user_instructions_tests.rs new file mode 100644 index 0000000000..58442600a8 --- /dev/null +++ b/codex-rs/core/src/instructions/user_instructions_tests.rs @@ -0,0 +1,68 @@ +use super::*; +use codex_protocol::models::ContentItem; +use pretty_assertions::assert_eq; + +#[test] +fn test_user_instructions() { + let user_instructions = UserInstructions { + directory: "test_directory".to_string(), + text: "test_text".to_string(), + }; + let response_item: ResponseItem = user_instructions.into(); + + let ResponseItem::Message { role, content, .. } = response_item else { + panic!("expected ResponseItem::Message"); + }; + + assert_eq!(role, "user"); + + let [ContentItem::InputText { text }] = content.as_slice() else { + panic!("expected one InputText content item"); + }; + + assert_eq!( + text, + "# AGENTS.md instructions for test_directory\n\n\ntest_text\n", + ); +} + +#[test] +fn test_is_user_instructions() { + assert!(AGENTS_MD_FRAGMENT.matches_text( + "# AGENTS.md instructions for test_directory\n\n\ntest_text\n" + )); + assert!(!AGENTS_MD_FRAGMENT.matches_text("test_text")); +} + +#[test] +fn test_skill_instructions() { + let skill_instructions = SkillInstructions { + name: "demo-skill".to_string(), + path: "skills/demo/SKILL.md".to_string(), + contents: "body".to_string(), + }; + let response_item: ResponseItem = skill_instructions.into(); + + let ResponseItem::Message { role, content, .. } = response_item else { + panic!("expected ResponseItem::Message"); + }; + + assert_eq!(role, "user"); + + let [ContentItem::InputText { text }] = content.as_slice() else { + panic!("expected one InputText content item"); + }; + + assert_eq!( + text, + "\ndemo-skill\nskills/demo/SKILL.md\nbody\n", + ); +} + +#[test] +fn test_is_skill_instructions() { + assert!(SKILL_FRAGMENT.matches_text( + "\ndemo-skill\nskills/demo/SKILL.md\nbody\n" + )); + assert!(!SKILL_FRAGMENT.matches_text("regular text")); +} diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index e90daf37f2..ea65595e11 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -11,7 +11,7 @@ use std::path::PathBuf; use tokio::process::Child; /// Spawn a shell tool command under the Linux sandbox helper -/// (codex-linux-sandbox), which currently uses bubblewrap for filesystem +/// (codex-linux-sandbox), which defaults to bubblewrap for filesystem /// isolation plus seccomp for network restrictions. /// /// Unlike macOS Seatbelt where we directly embed the policy text, the Linux @@ -25,7 +25,7 @@ pub async fn spawn_command_under_linux_sandbox

( command_cwd: PathBuf, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, - use_bwrap_sandbox: bool, + use_legacy_landlock: bool, stdio_policy: StdioPolicy, network: Option<&NetworkProxy>, env: HashMap, @@ -42,7 +42,7 @@ where &file_system_sandbox_policy, network_sandbox_policy, sandbox_policy_cwd, - use_bwrap_sandbox, + use_legacy_landlock, allow_network_for_proxy(false), ); let arg0 = Some("codex-linux-sandbox"); @@ -69,7 +69,7 @@ pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool { /// Converts the sandbox policies into the CLI invocation for /// `codex-linux-sandbox`. /// -/// The helper performs the actual sandboxing (bubblewrap + seccomp) after +/// The helper performs the actual sandboxing (bubblewrap by default + seccomp) after /// parsing these arguments. Policy JSON flags are emitted before helper feature /// flags so the argv order matches the helper's CLI shape. See /// `docs/linux_sandbox.md` for the Linux semantics. @@ -80,7 +80,7 @@ pub(crate) fn create_linux_sandbox_command_args_for_policies( file_system_sandbox_policy: &FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, sandbox_policy_cwd: &Path, - use_bwrap_sandbox: bool, + use_legacy_landlock: bool, allow_network_for_proxy: bool, ) -> Vec { let sandbox_policy_json = serde_json::to_string(sandbox_policy) @@ -104,8 +104,8 @@ pub(crate) fn create_linux_sandbox_command_args_for_policies( "--network-sandbox-policy".to_string(), network_policy_json, ]; - if use_bwrap_sandbox { - linux_cmd.push("--use-bwrap-sandbox".to_string()); + if use_legacy_landlock { + linux_cmd.push("--use-legacy-landlock".to_string()); } if allow_network_for_proxy { linux_cmd.push("--allow-network-for-proxy".to_string()); @@ -121,7 +121,7 @@ pub(crate) fn create_linux_sandbox_command_args_for_policies( pub(crate) fn create_linux_sandbox_command_args( command: Vec, sandbox_policy_cwd: &Path, - use_bwrap_sandbox: bool, + use_legacy_landlock: bool, allow_network_for_proxy: bool, ) -> Vec { let sandbox_policy_cwd = sandbox_policy_cwd @@ -130,8 +130,8 @@ pub(crate) fn create_linux_sandbox_command_args( .to_string(); let mut linux_cmd: Vec = vec!["--sandbox-policy-cwd".to_string(), sandbox_policy_cwd]; - if use_bwrap_sandbox { - linux_cmd.push("--use-bwrap-sandbox".to_string()); + if use_legacy_landlock { + linux_cmd.push("--use-legacy-landlock".to_string()); } if allow_network_for_proxy { linux_cmd.push("--allow-network-for-proxy".to_string()); @@ -148,75 +148,5 @@ pub(crate) fn create_linux_sandbox_command_args( } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn bwrap_flags_are_feature_gated() { - let command = vec!["/bin/true".to_string()]; - let cwd = Path::new("/tmp"); - - let with_bwrap = create_linux_sandbox_command_args(command.clone(), cwd, true, false); - assert_eq!( - with_bwrap.contains(&"--use-bwrap-sandbox".to_string()), - true - ); - - let without_bwrap = create_linux_sandbox_command_args(command, cwd, false, false); - assert_eq!( - without_bwrap.contains(&"--use-bwrap-sandbox".to_string()), - false - ); - } - - #[test] - fn proxy_flag_is_included_when_requested() { - let command = vec!["/bin/true".to_string()]; - let cwd = Path::new("/tmp"); - - let args = create_linux_sandbox_command_args(command, cwd, true, true); - assert_eq!( - args.contains(&"--allow-network-for-proxy".to_string()), - true - ); - } - - #[test] - fn split_policy_flags_are_included() { - let command = vec!["/bin/true".to_string()]; - let cwd = Path::new("/tmp"); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy); - let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); - - let args = create_linux_sandbox_command_args_for_policies( - command, - &sandbox_policy, - &file_system_sandbox_policy, - network_sandbox_policy, - cwd, - true, - false, - ); - - assert_eq!( - args.windows(2).any(|window| { - window[0] == "--file-system-sandbox-policy" && !window[1].is_empty() - }), - true - ); - assert_eq!( - args.windows(2) - .any(|window| window[0] == "--network-sandbox-policy" - && window[1] == "\"restricted\""), - true - ); - } - - #[test] - fn proxy_network_requires_managed_requirements() { - assert_eq!(allow_network_for_proxy(false), false); - assert_eq!(allow_network_for_proxy(true), true); - } -} +#[path = "landlock_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/landlock_tests.rs b/codex-rs/core/src/landlock_tests.rs new file mode 100644 index 0000000000..75b887e267 --- /dev/null +++ b/codex-rs/core/src/landlock_tests.rs @@ -0,0 +1,68 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn legacy_landlock_flag_is_included_when_requested() { + let command = vec!["/bin/true".to_string()]; + let cwd = Path::new("/tmp"); + + let default_bwrap = create_linux_sandbox_command_args(command.clone(), cwd, false, false); + assert_eq!( + default_bwrap.contains(&"--use-legacy-landlock".to_string()), + false + ); + + let legacy_landlock = create_linux_sandbox_command_args(command, cwd, true, false); + assert_eq!( + legacy_landlock.contains(&"--use-legacy-landlock".to_string()), + true + ); +} + +#[test] +fn proxy_flag_is_included_when_requested() { + let command = vec!["/bin/true".to_string()]; + let cwd = Path::new("/tmp"); + + let args = create_linux_sandbox_command_args(command, cwd, true, true); + assert_eq!( + args.contains(&"--allow-network-for-proxy".to_string()), + true + ); +} + +#[test] +fn split_policy_flags_are_included() { + let command = vec!["/bin/true".to_string()]; + let cwd = Path::new("/tmp"); + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); + + let args = create_linux_sandbox_command_args_for_policies( + command, + &sandbox_policy, + &file_system_sandbox_policy, + network_sandbox_policy, + cwd, + true, + false, + ); + + assert_eq!( + args.windows(2) + .any(|window| { window[0] == "--file-system-sandbox-policy" && !window[1].is_empty() }), + true + ); + assert_eq!( + args.windows(2) + .any(|window| window[0] == "--network-sandbox-policy" && window[1] == "\"restricted\""), + true + ); +} + +#[test] +fn proxy_network_requires_managed_requirements() { + assert_eq!(allow_network_for_proxy(false), false); + assert_eq!(allow_network_for_proxy(true), true); +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 7e84577ee6..51ea3f47ea 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -47,9 +47,11 @@ pub mod instructions; pub mod landlock; pub mod mcp; mod mcp_connection_manager; +mod mcp_tool_approval_templates; pub mod models_manager; mod network_policy_decision; pub mod network_proxy_loader; +mod original_image_detail; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 39eca4b29b..3140f5bcff 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -304,7 +304,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent sandbox_policy: SandboxPolicy::new_read_only_policy(), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), - use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap), + use_legacy_landlock: config.features.use_legacy_landlock(), }; let (mcp_connection_manager, cancel_token) = McpConnectionManager::new( @@ -469,344 +469,5 @@ pub(crate) async fn collect_mcp_snapshot_from_manager( } #[cfg(test)] -mod tests { - use super::*; - use crate::config::CONFIG_TOML_FILE; - use crate::config::ConfigBuilder; - use crate::plugins::AppConnectorId; - use crate::plugins::PluginCapabilitySummary; - use pretty_assertions::assert_eq; - use std::fs; - use std::path::Path; - use toml::Value; - - fn write_file(path: &Path, contents: &str) { - fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); - fs::write(path, contents).unwrap(); - } - - fn plugin_config_toml() -> String { - let mut root = toml::map::Map::new(); - - let mut features = toml::map::Map::new(); - features.insert("plugins".to_string(), Value::Boolean(true)); - root.insert("features".to_string(), Value::Table(features)); - - let mut plugin = toml::map::Map::new(); - plugin.insert("enabled".to_string(), Value::Boolean(true)); - - let mut plugins = toml::map::Map::new(); - plugins.insert("sample@test".to_string(), Value::Table(plugin)); - root.insert("plugins".to_string(), Value::Table(plugins)); - - toml::to_string(&Value::Table(root)).expect("plugin test config should serialize") - } - - fn make_tool(name: &str) -> Tool { - Tool { - name: name.to_string(), - title: None, - description: None, - input_schema: serde_json::json!({"type": "object", "properties": {}}), - output_schema: None, - annotations: None, - icons: None, - meta: None, - } - } - - #[test] - fn split_qualified_tool_name_returns_server_and_tool() { - assert_eq!( - split_qualified_tool_name("mcp__alpha__do_thing"), - Some(("alpha".to_string(), "do_thing".to_string())) - ); - } - - #[test] - fn split_qualified_tool_name_rejects_invalid_names() { - assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None); - assert_eq!(split_qualified_tool_name("mcp__alpha__"), None); - } - - #[test] - fn group_tools_by_server_strips_prefix_and_groups() { - let mut tools = HashMap::new(); - tools.insert("mcp__alpha__do_thing".to_string(), make_tool("do_thing")); - tools.insert( - "mcp__alpha__nested__op".to_string(), - make_tool("nested__op"), - ); - tools.insert("mcp__beta__do_other".to_string(), make_tool("do_other")); - - let mut expected_alpha = HashMap::new(); - expected_alpha.insert("do_thing".to_string(), make_tool("do_thing")); - expected_alpha.insert("nested__op".to_string(), make_tool("nested__op")); - - let mut expected_beta = HashMap::new(); - expected_beta.insert("do_other".to_string(), make_tool("do_other")); - - let mut expected = HashMap::new(); - expected.insert("alpha".to_string(), expected_alpha); - expected.insert("beta".to_string(), expected_beta); - - assert_eq!(group_tools_by_server(&tools), expected); - } - - #[test] - fn tool_plugin_provenance_collects_app_and_mcp_sources() { - let provenance = ToolPluginProvenance::from_capability_summaries(&[ - PluginCapabilitySummary { - display_name: "alpha-plugin".to_string(), - app_connector_ids: vec![AppConnectorId("connector_example".to_string())], - mcp_server_names: vec!["alpha".to_string()], - ..PluginCapabilitySummary::default() - }, - PluginCapabilitySummary { - display_name: "beta-plugin".to_string(), - app_connector_ids: vec![ - AppConnectorId("connector_example".to_string()), - AppConnectorId("connector_gmail".to_string()), - ], - mcp_server_names: vec!["beta".to_string()], - ..PluginCapabilitySummary::default() - }, - ]); - - assert_eq!( - provenance, - ToolPluginProvenance { - plugin_display_names_by_connector_id: HashMap::from([ - ( - "connector_example".to_string(), - vec!["alpha-plugin".to_string(), "beta-plugin".to_string()], - ), - ( - "connector_gmail".to_string(), - vec!["beta-plugin".to_string()], - ), - ]), - plugin_display_names_by_mcp_server_name: HashMap::from([ - ("alpha".to_string(), vec!["alpha-plugin".to_string()]), - ("beta".to_string(), vec!["beta-plugin".to_string()]), - ]), - } - ); - } - - #[test] - fn codex_apps_mcp_url_for_default_gateway_keeps_existing_paths() { - assert_eq!( - codex_apps_mcp_url_for_gateway( - "https://chatgpt.com/backend-api", - CodexAppsMcpGateway::LegacyMCPGateway - ), - "https://chatgpt.com/backend-api/wham/apps" - ); - assert_eq!( - codex_apps_mcp_url_for_gateway( - "https://chat.openai.com", - CodexAppsMcpGateway::LegacyMCPGateway - ), - "https://chat.openai.com/backend-api/wham/apps" - ); - assert_eq!( - codex_apps_mcp_url_for_gateway( - "http://localhost:8080/api/codex", - CodexAppsMcpGateway::LegacyMCPGateway - ), - "http://localhost:8080/api/codex/apps" - ); - assert_eq!( - codex_apps_mcp_url_for_gateway( - "http://localhost:8080", - CodexAppsMcpGateway::LegacyMCPGateway - ), - "http://localhost:8080/api/codex/apps" - ); - } - - #[test] - fn codex_apps_mcp_url_for_gateway_uses_openai_connectors_gateway() { - let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}"); - - assert_eq!( - codex_apps_mcp_url_for_gateway( - "https://chatgpt.com/backend-api", - CodexAppsMcpGateway::MCPGateway - ), - expected_url.as_str() - ); - assert_eq!( - codex_apps_mcp_url_for_gateway( - "https://chat.openai.com", - CodexAppsMcpGateway::MCPGateway - ), - expected_url.as_str() - ); - assert_eq!( - codex_apps_mcp_url_for_gateway( - "http://localhost:8080/api/codex", - CodexAppsMcpGateway::MCPGateway - ), - expected_url.as_str() - ); - assert_eq!( - codex_apps_mcp_url_for_gateway( - "http://localhost:8080", - CodexAppsMcpGateway::MCPGateway - ), - expected_url.as_str() - ); - } - - #[test] - fn codex_apps_mcp_url_uses_default_gateway_when_feature_is_disabled() { - let mut config = crate::config::test_config(); - config.chatgpt_base_url = "https://chatgpt.com".to_string(); - - assert_eq!( - codex_apps_mcp_url(&config), - "https://chatgpt.com/backend-api/wham/apps" - ); - } - - #[test] - fn codex_apps_mcp_url_uses_openai_connectors_gateway_when_feature_is_enabled() { - let mut config = crate::config::test_config(); - config.chatgpt_base_url = "https://chatgpt.com".to_string(); - config - .features - .enable(Feature::AppsMcpGateway) - .expect("test config should allow apps gateway"); - - assert_eq!( - codex_apps_mcp_url(&config), - format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}") - ); - } - - #[test] - fn codex_apps_server_config_switches_gateway_with_flags() { - let mut config = crate::config::test_config(); - config.chatgpt_base_url = "https://chatgpt.com".to_string(); - - let mut servers = with_codex_apps_mcp(HashMap::new(), false, None, &config); - assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME)); - - config - .features - .enable(Feature::Apps) - .expect("test config should allow apps"); - - servers = with_codex_apps_mcp(servers, true, None, &config); - let server = servers - .get(CODEX_APPS_MCP_SERVER_NAME) - .expect("codex apps should be present when apps is enabled"); - let url = match &server.transport { - McpServerTransportConfig::StreamableHttp { url, .. } => url, - _ => panic!("expected streamable http transport for codex apps"), - }; - - assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps"); - - config - .features - .enable(Feature::AppsMcpGateway) - .expect("test config should allow apps gateway"); - servers = with_codex_apps_mcp(servers, true, None, &config); - let server = servers - .get(CODEX_APPS_MCP_SERVER_NAME) - .expect("codex apps should remain present when apps stays enabled"); - let url = match &server.transport { - McpServerTransportConfig::StreamableHttp { url, .. } => url, - _ => panic!("expected streamable http transport for codex apps"), - }; - - let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}"); - assert_eq!(url, &expected_url); - } - - #[tokio::test] - async fn effective_mcp_servers_include_plugins_without_overriding_user_config() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ); - write_file( - &plugin_root.join(".mcp.json"), - r#"{ - "mcpServers": { - "sample": { - "type": "http", - "url": "https://plugin.example/mcp" - }, - "docs": { - "type": "http", - "url": "https://docs.example/mcp" - } - } -}"#, - ); - write_file( - &codex_home.path().join(CONFIG_TOML_FILE), - &plugin_config_toml(), - ); - - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("config should load"); - - let mut configured_servers = config.mcp_servers.get().clone(); - configured_servers.insert( - "sample".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://user.example/mcp".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - ); - config - .mcp_servers - .set(configured_servers) - .expect("test config should accept MCP servers"); - - let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); - let effective = mcp_manager.effective_servers(&config, None); - - let sample = effective.get("sample").expect("user server should exist"); - let docs = effective.get("docs").expect("plugin server should exist"); - - match &sample.transport { - McpServerTransportConfig::StreamableHttp { url, .. } => { - assert_eq!(url, "https://user.example/mcp"); - } - other => panic!("expected streamable http transport, got {other:?}"), - } - match &docs.transport { - McpServerTransportConfig::StreamableHttp { url, .. } => { - assert_eq!(url, "https://docs.example/mcp"); - } - other => panic!("expected streamable http transport, got {other:?}"), - } - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/mcp/mod_tests.rs b/codex-rs/core/src/mcp/mod_tests.rs new file mode 100644 index 0000000000..cdbcda2ea0 --- /dev/null +++ b/codex-rs/core/src/mcp/mod_tests.rs @@ -0,0 +1,333 @@ +use super::*; +use crate::config::CONFIG_TOML_FILE; +use crate::config::ConfigBuilder; +use crate::plugins::AppConnectorId; +use crate::plugins::PluginCapabilitySummary; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use toml::Value; + +fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + fs::write(path, contents).unwrap(); +} + +fn plugin_config_toml() -> String { + let mut root = toml::map::Map::new(); + + let mut features = toml::map::Map::new(); + features.insert("plugins".to_string(), Value::Boolean(true)); + root.insert("features".to_string(), Value::Table(features)); + + let mut plugin = toml::map::Map::new(); + plugin.insert("enabled".to_string(), Value::Boolean(true)); + + let mut plugins = toml::map::Map::new(); + plugins.insert("sample@test".to_string(), Value::Table(plugin)); + root.insert("plugins".to_string(), Value::Table(plugins)); + + toml::to_string(&Value::Table(root)).expect("plugin test config should serialize") +} + +fn make_tool(name: &str) -> Tool { + Tool { + name: name.to_string(), + title: None, + description: None, + input_schema: serde_json::json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + icons: None, + meta: None, + } +} + +#[test] +fn split_qualified_tool_name_returns_server_and_tool() { + assert_eq!( + split_qualified_tool_name("mcp__alpha__do_thing"), + Some(("alpha".to_string(), "do_thing".to_string())) + ); +} + +#[test] +fn split_qualified_tool_name_rejects_invalid_names() { + assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None); + assert_eq!(split_qualified_tool_name("mcp__alpha__"), None); +} + +#[test] +fn group_tools_by_server_strips_prefix_and_groups() { + let mut tools = HashMap::new(); + tools.insert("mcp__alpha__do_thing".to_string(), make_tool("do_thing")); + tools.insert( + "mcp__alpha__nested__op".to_string(), + make_tool("nested__op"), + ); + tools.insert("mcp__beta__do_other".to_string(), make_tool("do_other")); + + let mut expected_alpha = HashMap::new(); + expected_alpha.insert("do_thing".to_string(), make_tool("do_thing")); + expected_alpha.insert("nested__op".to_string(), make_tool("nested__op")); + + let mut expected_beta = HashMap::new(); + expected_beta.insert("do_other".to_string(), make_tool("do_other")); + + let mut expected = HashMap::new(); + expected.insert("alpha".to_string(), expected_alpha); + expected.insert("beta".to_string(), expected_beta); + + assert_eq!(group_tools_by_server(&tools), expected); +} + +#[test] +fn tool_plugin_provenance_collects_app_and_mcp_sources() { + let provenance = ToolPluginProvenance::from_capability_summaries(&[ + PluginCapabilitySummary { + display_name: "alpha-plugin".to_string(), + app_connector_ids: vec![AppConnectorId("connector_example".to_string())], + mcp_server_names: vec!["alpha".to_string()], + ..PluginCapabilitySummary::default() + }, + PluginCapabilitySummary { + display_name: "beta-plugin".to_string(), + app_connector_ids: vec![ + AppConnectorId("connector_example".to_string()), + AppConnectorId("connector_gmail".to_string()), + ], + mcp_server_names: vec!["beta".to_string()], + ..PluginCapabilitySummary::default() + }, + ]); + + assert_eq!( + provenance, + ToolPluginProvenance { + plugin_display_names_by_connector_id: HashMap::from([ + ( + "connector_example".to_string(), + vec!["alpha-plugin".to_string(), "beta-plugin".to_string()], + ), + ( + "connector_gmail".to_string(), + vec!["beta-plugin".to_string()], + ), + ]), + plugin_display_names_by_mcp_server_name: HashMap::from([ + ("alpha".to_string(), vec!["alpha-plugin".to_string()]), + ("beta".to_string(), vec!["beta-plugin".to_string()]), + ]), + } + ); +} + +#[test] +fn codex_apps_mcp_url_for_default_gateway_keeps_existing_paths() { + assert_eq!( + codex_apps_mcp_url_for_gateway( + "https://chatgpt.com/backend-api", + CodexAppsMcpGateway::LegacyMCPGateway + ), + "https://chatgpt.com/backend-api/wham/apps" + ); + assert_eq!( + codex_apps_mcp_url_for_gateway( + "https://chat.openai.com", + CodexAppsMcpGateway::LegacyMCPGateway + ), + "https://chat.openai.com/backend-api/wham/apps" + ); + assert_eq!( + codex_apps_mcp_url_for_gateway( + "http://localhost:8080/api/codex", + CodexAppsMcpGateway::LegacyMCPGateway + ), + "http://localhost:8080/api/codex/apps" + ); + assert_eq!( + codex_apps_mcp_url_for_gateway( + "http://localhost:8080", + CodexAppsMcpGateway::LegacyMCPGateway + ), + "http://localhost:8080/api/codex/apps" + ); +} + +#[test] +fn codex_apps_mcp_url_for_gateway_uses_openai_connectors_gateway() { + let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}"); + + assert_eq!( + codex_apps_mcp_url_for_gateway( + "https://chatgpt.com/backend-api", + CodexAppsMcpGateway::MCPGateway + ), + expected_url.as_str() + ); + assert_eq!( + codex_apps_mcp_url_for_gateway("https://chat.openai.com", CodexAppsMcpGateway::MCPGateway), + expected_url.as_str() + ); + assert_eq!( + codex_apps_mcp_url_for_gateway( + "http://localhost:8080/api/codex", + CodexAppsMcpGateway::MCPGateway + ), + expected_url.as_str() + ); + assert_eq!( + codex_apps_mcp_url_for_gateway("http://localhost:8080", CodexAppsMcpGateway::MCPGateway), + expected_url.as_str() + ); +} + +#[test] +fn codex_apps_mcp_url_uses_default_gateway_when_feature_is_disabled() { + let mut config = crate::config::test_config(); + config.chatgpt_base_url = "https://chatgpt.com".to_string(); + + assert_eq!( + codex_apps_mcp_url(&config), + "https://chatgpt.com/backend-api/wham/apps" + ); +} + +#[test] +fn codex_apps_mcp_url_uses_openai_connectors_gateway_when_feature_is_enabled() { + let mut config = crate::config::test_config(); + config.chatgpt_base_url = "https://chatgpt.com".to_string(); + config + .features + .enable(Feature::AppsMcpGateway) + .expect("test config should allow apps gateway"); + + assert_eq!( + codex_apps_mcp_url(&config), + format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}") + ); +} + +#[test] +fn codex_apps_server_config_switches_gateway_with_flags() { + let mut config = crate::config::test_config(); + config.chatgpt_base_url = "https://chatgpt.com".to_string(); + + let mut servers = with_codex_apps_mcp(HashMap::new(), false, None, &config); + assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME)); + + config + .features + .enable(Feature::Apps) + .expect("test config should allow apps"); + + servers = with_codex_apps_mcp(servers, true, None, &config); + let server = servers + .get(CODEX_APPS_MCP_SERVER_NAME) + .expect("codex apps should be present when apps is enabled"); + let url = match &server.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => url, + _ => panic!("expected streamable http transport for codex apps"), + }; + + assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps"); + + config + .features + .enable(Feature::AppsMcpGateway) + .expect("test config should allow apps gateway"); + servers = with_codex_apps_mcp(servers, true, None, &config); + let server = servers + .get(CODEX_APPS_MCP_SERVER_NAME) + .expect("codex apps should remain present when apps stays enabled"); + let url = match &server.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => url, + _ => panic!("expected streamable http transport for codex apps"), + }; + + let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}"); + assert_eq!(url, &expected_url); +} + +#[tokio::test] +async fn effective_mcp_servers_include_plugins_without_overriding_user_config() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://plugin.example/mcp" + }, + "docs": { + "type": "http", + "url": "https://docs.example/mcp" + } + } +}"#, + ); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml(), + ); + + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config should load"); + + let mut configured_servers = config.mcp_servers.get().clone(); + configured_servers.insert( + "sample".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://user.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + ); + config + .mcp_servers + .set(configured_servers) + .expect("test config should accept MCP servers"); + + let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); + let effective = mcp_manager.effective_servers(&config, None); + + let sample = effective.get("sample").expect("user server should exist"); + let docs = effective.get("docs").expect("plugin server should exist"); + + match &sample.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + assert_eq!(url, "https://user.example/mcp"); + } + other => panic!("expected streamable http transport, got {other:?}"), + } + match &docs.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + assert_eq!(url, "https://docs.example/mcp"); + } + other => panic!("expected streamable http transport, got {other:?}"), + } +} diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp/skill_dependencies.rs index f15bb6ec57..e9d77a33f4 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp/skill_dependencies.rs @@ -426,111 +426,5 @@ fn mcp_dependency_to_server_config( } #[cfg(test)] -mod tests { - use super::*; - use crate::skills::model::SkillDependencies; - use codex_protocol::protocol::SkillScope; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - - fn skill_with_tools(tools: Vec) -> SkillMetadata { - SkillMetadata { - name: "skill".to_string(), - description: "skill".to_string(), - short_description: None, - interface: None, - dependencies: Some(SkillDependencies { tools }), - policy: None, - permission_profile: None, - path_to_skills_md: PathBuf::from("skill"), - scope: SkillScope::User, - } - } - - #[test] - fn collect_missing_respects_canonical_installed_key() { - let url = "https://example.com/mcp".to_string(); - let skills = vec![skill_with_tools(vec![SkillToolDependency { - r#type: "mcp".to_string(), - value: "github".to_string(), - description: None, - transport: Some("streamable_http".to_string()), - command: None, - url: Some(url.clone()), - }])]; - let installed = HashMap::from([( - "alias".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - )]); - - assert_eq!( - collect_missing_mcp_dependencies(&skills, &installed), - HashMap::new() - ); - } - - #[test] - fn collect_missing_dedupes_by_canonical_key_but_preserves_original_name() { - let url = "https://example.com/one".to_string(); - let skills = vec![skill_with_tools(vec![ - SkillToolDependency { - r#type: "mcp".to_string(), - value: "alias-one".to_string(), - description: None, - transport: Some("streamable_http".to_string()), - command: None, - url: Some(url.clone()), - }, - SkillToolDependency { - r#type: "mcp".to_string(), - value: "alias-two".to_string(), - description: None, - transport: Some("streamable_http".to_string()), - command: None, - url: Some(url.clone()), - }, - ])]; - - let expected = HashMap::from([( - "alias-one".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - )]); - - assert_eq!( - collect_missing_mcp_dependencies(&skills, &HashMap::new()), - expected - ); - } -} +#[path = "skill_dependencies_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/mcp/skill_dependencies_tests.rs b/codex-rs/core/src/mcp/skill_dependencies_tests.rs new file mode 100644 index 0000000000..68af0df984 --- /dev/null +++ b/codex-rs/core/src/mcp/skill_dependencies_tests.rs @@ -0,0 +1,106 @@ +use super::*; +use crate::skills::model::SkillDependencies; +use codex_protocol::protocol::SkillScope; +use pretty_assertions::assert_eq; +use std::path::PathBuf; + +fn skill_with_tools(tools: Vec) -> SkillMetadata { + SkillMetadata { + name: "skill".to_string(), + description: "skill".to_string(), + short_description: None, + interface: None, + dependencies: Some(SkillDependencies { tools }), + policy: None, + permission_profile: None, + path_to_skills_md: PathBuf::from("skill"), + scope: SkillScope::User, + } +} + +#[test] +fn collect_missing_respects_canonical_installed_key() { + let url = "https://example.com/mcp".to_string(); + let skills = vec![skill_with_tools(vec![SkillToolDependency { + r#type: "mcp".to_string(), + value: "github".to_string(), + description: None, + transport: Some("streamable_http".to_string()), + command: None, + url: Some(url.clone()), + }])]; + let installed = HashMap::from([( + "alias".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]); + + assert_eq!( + collect_missing_mcp_dependencies(&skills, &installed), + HashMap::new() + ); +} + +#[test] +fn collect_missing_dedupes_by_canonical_key_but_preserves_original_name() { + let url = "https://example.com/one".to_string(); + let skills = vec![skill_with_tools(vec![ + SkillToolDependency { + r#type: "mcp".to_string(), + value: "alias-one".to_string(), + description: None, + transport: Some("streamable_http".to_string()), + command: None, + url: Some(url.clone()), + }, + SkillToolDependency { + r#type: "mcp".to_string(), + value: "alias-two".to_string(), + description: None, + transport: Some("streamable_http".to_string()), + command: None, + url: Some(url.clone()), + }, + ])]; + + let expected = HashMap::from([( + "alias-one".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]); + + assert_eq!( + collect_missing_mcp_dependencies(&skills, &HashMap::new()), + expected + ); +} diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 442e7e0c6e..c93bb13d7c 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -82,6 +82,8 @@ use crate::codex::INITIAL_SUBMIT_ID; use crate::config::types::McpServerConfig; use crate::config::types::McpServerTransportConfig; use crate::connectors::is_connector_id_allowed; +use crate::connectors::sanitize_name; + /// Delimiter used to separate the server name from the tool name in a fully /// qualified tool name. /// @@ -158,10 +160,14 @@ where let mut seen_raw_names = HashSet::new(); let mut qualified_tools = HashMap::new(); for tool in tools { - let qualified_name_raw = format!( - "mcp{}{}{}{}", - MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name - ); + let qualified_name_raw = if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { + format!( + "mcp{}{}{}{}", + MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name + ) + } else { + format!("{}{}", tool.tool_namespace, tool.tool_name) + }; if !seen_raw_names.insert(qualified_name_raw.clone()) { warn!("skipping duplicated tool {}", qualified_name_raw); continue; @@ -196,11 +202,13 @@ where pub(crate) struct ToolInfo { pub(crate) server_name: String, pub(crate) tool_name: String, + pub(crate) tool_namespace: String, pub(crate) tool: Tool, pub(crate) connector_id: Option, pub(crate) connector_name: Option, #[serde(default)] pub(crate) plugin_display_names: Vec, + pub(crate) connector_description: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -583,7 +591,7 @@ pub struct SandboxState { pub codex_linux_sandbox_exe: Option, pub sandbox_cwd: PathBuf, #[serde(default)] - pub use_linux_sandbox_bwrap: bool, + pub use_legacy_landlock: bool, } /// A thin wrapper around a set of running [`RmcpClient`] instances. @@ -819,9 +827,10 @@ impl McpConnectionManager { /// Force-refresh codex apps tools by bypassing the in-process cache. /// - /// On success, the refreshed tools replace the cache contents. On failure, - /// the existing cache remains unchanged. - pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<()> { + /// On success, the refreshed tools replace the cache contents and the + /// latest filtered tool map is returned directly to the caller. On + /// failure, the existing cache remains unchanged. + pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result> { let managed_client = self .clients .get(CODEX_APPS_MCP_SERVER_NAME) @@ -857,7 +866,10 @@ impl McpConnectionManager { list_start.elapsed(), &[("cache", "miss")], ); - Ok(()) + Ok(qualify_tools(filter_tools( + tools, + &managed_client.tool_filter, + ))) } /// Returns a single map that contains all resources. Each key is the @@ -1086,7 +1098,7 @@ impl McpConnectionManager { self.list_all_tools() .await .get(tool_name) - .map(|tool| (tool.server_name.clone(), tool.tool_name.clone())) + .map(|tool| (tool.server_name.clone(), tool.tool.name.to_string())) } pub async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> { @@ -1168,31 +1180,7 @@ impl ToolFilter { fn filter_tools(tools: Vec, filter: &ToolFilter) -> Vec { tools .into_iter() - .filter(|tool| filter.allows(&tool.tool_name)) - .collect() -} - -pub(crate) fn filter_codex_apps_mcp_tools_only( - mcp_tools: &HashMap, - connectors: &[crate::connectors::AppInfo], -) -> HashMap { - let allowed: HashSet<&str> = connectors - .iter() - .map(|connector| connector.id.as_str()) - .collect(); - - mcp_tools - .iter() - .filter(|(_, tool)| { - if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { - return false; - } - let Some(connector_id) = tool.connector_id.as_deref() else { - return false; - }; - allowed.contains(connector_id) - }) - .map(|(name, tool)| (name.clone(), tool.clone())) + .filter(|tool| filter.allows(&tool.tool.name)) .collect() } @@ -1206,19 +1194,6 @@ pub(crate) fn filter_non_codex_apps_mcp_tools_only( .collect() } -pub(crate) fn filter_mcp_tools_by_name( - mcp_tools: &HashMap, - selected_tools: &[String], -) -> HashMap { - let allowed: HashSet<&str> = selected_tools.iter().map(String::as_str).collect(); - - mcp_tools - .iter() - .filter(|(name, _)| allowed.contains(name.as_str())) - .map(|(name, tool)| (name.clone(), tool.clone())) - .collect() -} - fn normalize_codex_apps_tool_title( server_name: &str, connector_name: Option<&str>, @@ -1245,6 +1220,57 @@ fn normalize_codex_apps_tool_title( value.to_string() } +fn normalize_codex_apps_tool_name( + server_name: &str, + tool_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, +) -> String { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return tool_name.to_string(); + } + + let tool_name = sanitize_name(tool_name); + + if let Some(connector_name) = connector_name + .map(str::trim) + .map(sanitize_name) + .filter(|name| !name.is_empty()) + && let Some(stripped) = tool_name.strip_prefix(&connector_name) + && !stripped.is_empty() + { + return stripped.to_string(); + } + + if let Some(connector_id) = connector_id + .map(str::trim) + .map(sanitize_name) + .filter(|name| !name.is_empty()) + && let Some(stripped) = tool_name.strip_prefix(&connector_id) + && !stripped.is_empty() + { + return stripped.to_string(); + } + + tool_name +} + +fn normalize_codex_apps_namespace(server_name: &str, connector_name: Option<&str>) -> String { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + server_name.to_string() + } else if let Some(connector_name) = connector_name { + format!( + "mcp{}{}{}{}", + MCP_TOOL_NAME_DELIMITER, + server_name, + MCP_TOOL_NAME_DELIMITER, + sanitize_name(connector_name) + ) + } else { + server_name.to_string() + } +} + fn resolve_bearer_token( server_name: &str, bearer_token_env_var: Option<&str>, @@ -1563,7 +1589,16 @@ async fn list_tools_for_client_uncached( .tools .into_iter() .map(|tool| { + let tool_name = normalize_codex_apps_tool_name( + server_name, + &tool.tool.name, + tool.connector_id.as_deref(), + tool.connector_name.as_deref(), + ); + let tool_namespace = + normalize_codex_apps_namespace(server_name, tool.connector_name.as_deref()); let connector_name = tool.connector_name; + let connector_description = tool.connector_description; let mut tool_def = tool.tool; if let Some(title) = tool_def.title.as_deref() { let normalized_title = @@ -1574,11 +1609,13 @@ async fn list_tools_for_client_uncached( } ToolInfo { server_name: server_name.to_owned(), - tool_name: tool_def.name.to_string(), + tool_name, + tool_namespace, tool: tool_def, connector_id: tool.connector_id, connector_name, plugin_display_names: Vec::new(), + connector_description, } }) .collect(); @@ -1666,647 +1703,5 @@ fn startup_outcome_error_message(error: StartupOutcomeError) -> String { mod mcp_init_error_display_tests {} #[cfg(test)] -mod tests { - use super::*; - use codex_protocol::protocol::McpAuthStatus; - use codex_protocol::protocol::RejectConfig; - use rmcp::model::JsonObject; - use std::collections::HashSet; - use std::sync::Arc; - use tempfile::tempdir; - - fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { - ToolInfo { - server_name: server_name.to_string(), - tool_name: tool_name.to_string(), - tool: Tool { - name: tool_name.to_string().into(), - title: None, - description: Some(format!("Test tool: {tool_name}").into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, - connector_id: None, - connector_name: None, - plugin_display_names: Vec::new(), - } - } - - fn create_test_tool_with_connector( - server_name: &str, - tool_name: &str, - connector_id: &str, - connector_name: Option<&str>, - ) -> ToolInfo { - let mut tool = create_test_tool(server_name, tool_name); - tool.connector_id = Some(connector_id.to_string()); - tool.connector_name = connector_name.map(ToOwned::to_owned); - tool - } - - fn create_codex_apps_tools_cache_context( - codex_home: PathBuf, - account_id: Option<&str>, - chatgpt_user_id: Option<&str>, - ) -> CodexAppsToolsCacheContext { - CodexAppsToolsCacheContext { - codex_home, - user_key: CodexAppsToolsCacheKey { - account_id: account_id.map(ToOwned::to_owned), - chatgpt_user_id: chatgpt_user_id.map(ToOwned::to_owned), - is_workspace_account: false, - }, - } - } - - #[test] - fn elicitation_reject_policy_defaults_to_prompting() { - assert!(!elicitation_is_rejected_by_policy( - AskForApproval::OnFailure - )); - assert!(!elicitation_is_rejected_by_policy( - AskForApproval::OnRequest - )); - assert!(!elicitation_is_rejected_by_policy( - AskForApproval::UnlessTrusted - )); - assert!(!elicitation_is_rejected_by_policy(AskForApproval::Reject( - RejectConfig { - sandbox_approval: false, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - } - ))); - } - - #[test] - fn elicitation_reject_policy_respects_never_and_reject_config() { - assert!(elicitation_is_rejected_by_policy(AskForApproval::Never)); - assert!(elicitation_is_rejected_by_policy(AskForApproval::Reject( - RejectConfig { - sandbox_approval: false, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - } - ))); - } - - #[test] - fn test_qualify_tools_short_non_duplicated_names() { - let tools = vec![ - create_test_tool("server1", "tool1"), - create_test_tool("server1", "tool2"), - ]; - - let qualified_tools = qualify_tools(tools); - - assert_eq!(qualified_tools.len(), 2); - assert!(qualified_tools.contains_key("mcp__server1__tool1")); - assert!(qualified_tools.contains_key("mcp__server1__tool2")); - } - - #[test] - fn test_qualify_tools_duplicated_names_skipped() { - let tools = vec![ - create_test_tool("server1", "duplicate_tool"), - create_test_tool("server1", "duplicate_tool"), - ]; - - let qualified_tools = qualify_tools(tools); - - // Only the first tool should remain, the second is skipped - assert_eq!(qualified_tools.len(), 1); - assert!(qualified_tools.contains_key("mcp__server1__duplicate_tool")); - } - - #[test] - fn test_qualify_tools_long_names_same_server() { - let server_name = "my_server"; - - let tools = vec![ - create_test_tool( - server_name, - "extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits", - ), - create_test_tool( - server_name, - "yet_another_extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits", - ), - ]; - - let qualified_tools = qualify_tools(tools); - - assert_eq!(qualified_tools.len(), 2); - - let mut keys: Vec<_> = qualified_tools.keys().cloned().collect(); - keys.sort(); - - assert_eq!(keys[0].len(), 64); - assert_eq!( - keys[0], - "mcp__my_server__extremel119a2b97664e41363932dc84de21e2ff1b93b3e9" - ); - - assert_eq!(keys[1].len(), 64); - assert_eq!( - keys[1], - "mcp__my_server__yet_anot419a82a89325c1b477274a41f8c65ea5f3a7f341" - ); - } - - #[test] - fn test_qualify_tools_sanitizes_invalid_characters() { - let tools = vec![create_test_tool("server.one", "tool.two")]; - - let qualified_tools = qualify_tools(tools); - - assert_eq!(qualified_tools.len(), 1); - let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool"); - assert_eq!(qualified_name, "mcp__server_one__tool_two"); - - // The key is sanitized for OpenAI, but we keep original parts for the actual MCP call. - assert_eq!(tool.server_name, "server.one"); - assert_eq!(tool.tool_name, "tool.two"); - - assert!( - qualified_name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'), - "qualified name must be Responses API compatible: {qualified_name:?}" - ); - } - - #[test] - fn tool_filter_allows_by_default() { - let filter = ToolFilter::default(); - - assert!(filter.allows("any")); - } - - #[test] - fn tool_filter_applies_enabled_list() { - let filter = ToolFilter { - enabled: Some(HashSet::from(["allowed".to_string()])), - disabled: HashSet::new(), - }; - - assert!(filter.allows("allowed")); - assert!(!filter.allows("denied")); - } - - #[test] - fn tool_filter_applies_disabled_list() { - let filter = ToolFilter { - enabled: None, - disabled: HashSet::from(["blocked".to_string()]), - }; - - assert!(!filter.allows("blocked")); - assert!(filter.allows("open")); - } - - #[test] - fn tool_filter_applies_enabled_then_disabled() { - let filter = ToolFilter { - enabled: Some(HashSet::from(["keep".to_string(), "remove".to_string()])), - disabled: HashSet::from(["remove".to_string()]), - }; - - assert!(filter.allows("keep")); - assert!(!filter.allows("remove")); - assert!(!filter.allows("unknown")); - } - - #[test] - fn filter_tools_applies_per_server_filters() { - let server1_tools = vec![ - create_test_tool("server1", "tool_a"), - create_test_tool("server1", "tool_b"), - ]; - let server2_tools = vec![create_test_tool("server2", "tool_a")]; - let server1_filter = ToolFilter { - enabled: Some(HashSet::from(["tool_a".to_string(), "tool_b".to_string()])), - disabled: HashSet::from(["tool_b".to_string()]), - }; - let server2_filter = ToolFilter { - enabled: None, - disabled: HashSet::from(["tool_a".to_string()]), - }; - - let filtered: Vec<_> = filter_tools(server1_tools, &server1_filter) - .into_iter() - .chain(filter_tools(server2_tools, &server2_filter)) - .collect(); - - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].server_name, "server1"); - assert_eq!(filtered[0].tool_name, "tool_a"); - } - - #[test] - fn codex_apps_tools_cache_is_overwritten_by_last_write() { - let codex_home = tempdir().expect("tempdir"); - let cache_context = create_codex_apps_tools_cache_context( - codex_home.path().to_path_buf(), - Some("account-one"), - Some("user-one"), - ); - let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; - let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; - - write_cached_codex_apps_tools(&cache_context, &tools_gateway_1); - let cached_gateway_1 = read_cached_codex_apps_tools(&cache_context) - .expect("cache entry exists for first write"); - assert_eq!(cached_gateway_1[0].tool_name, "one"); - - write_cached_codex_apps_tools(&cache_context, &tools_gateway_2); - let cached_gateway_2 = read_cached_codex_apps_tools(&cache_context) - .expect("cache entry exists for second write"); - assert_eq!(cached_gateway_2[0].tool_name, "two"); - } - - #[test] - fn codex_apps_tools_cache_is_scoped_per_user() { - let codex_home = tempdir().expect("tempdir"); - let cache_context_user_1 = create_codex_apps_tools_cache_context( - codex_home.path().to_path_buf(), - Some("account-one"), - Some("user-one"), - ); - let cache_context_user_2 = create_codex_apps_tools_cache_context( - codex_home.path().to_path_buf(), - Some("account-two"), - Some("user-two"), - ); - let tools_user_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; - let tools_user_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; - - write_cached_codex_apps_tools(&cache_context_user_1, &tools_user_1); - write_cached_codex_apps_tools(&cache_context_user_2, &tools_user_2); - - let read_user_1 = - read_cached_codex_apps_tools(&cache_context_user_1).expect("cache entry for user one"); - let read_user_2 = - read_cached_codex_apps_tools(&cache_context_user_2).expect("cache entry for user two"); - - assert_eq!(read_user_1[0].tool_name, "one"); - assert_eq!(read_user_2[0].tool_name, "two"); - assert_ne!( - cache_context_user_1.cache_path(), - cache_context_user_2.cache_path(), - "each user should get an isolated cache file" - ); - } - - #[test] - fn codex_apps_tools_cache_filters_disallowed_connectors() { - let codex_home = tempdir().expect("tempdir"); - let cache_context = create_codex_apps_tools_cache_context( - codex_home.path().to_path_buf(), - Some("account-one"), - Some("user-one"), - ); - let tools = vec![ - create_test_tool_with_connector( - CODEX_APPS_MCP_SERVER_NAME, - "blocked_tool", - "connector_openai_hidden", - Some("Hidden"), - ), - create_test_tool_with_connector( - CODEX_APPS_MCP_SERVER_NAME, - "allowed_tool", - "calendar", - Some("Calendar"), - ), - ]; - - write_cached_codex_apps_tools(&cache_context, &tools); - let cached = - read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user"); - - assert_eq!(cached.len(), 1); - assert_eq!(cached[0].tool_name, "allowed_tool"); - assert_eq!(cached[0].connector_id.as_deref(), Some("calendar")); - } - - #[test] - fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() { - let codex_home = tempdir().expect("tempdir"); - let cache_context = create_codex_apps_tools_cache_context( - codex_home.path().to_path_buf(), - Some("account-one"), - Some("user-one"), - ); - let cache_path = cache_context.cache_path(); - if let Some(parent) = cache_path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - let bytes = serde_json::to_vec_pretty(&serde_json::json!({ - "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION + 1, - "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")], - })) - .expect("serialize"); - std::fs::write(cache_path, bytes).expect("write"); - - assert!(read_cached_codex_apps_tools(&cache_context).is_none()); - } - - #[test] - fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() { - let codex_home = tempdir().expect("tempdir"); - let cache_context = create_codex_apps_tools_cache_context( - codex_home.path().to_path_buf(), - Some("account-one"), - Some("user-one"), - ); - let cache_path = cache_context.cache_path(); - if let Some(parent) = cache_path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::write(cache_path, b"{not json").expect("write"); - - assert!(read_cached_codex_apps_tools(&cache_context).is_none()); - } - - #[test] - fn startup_cached_codex_apps_tools_loads_from_disk_cache() { - let codex_home = tempdir().expect("tempdir"); - let cache_context = create_codex_apps_tools_cache_context( - codex_home.path().to_path_buf(), - Some("account-one"), - Some("user-one"), - ); - let cached_tools = vec![create_test_tool( - CODEX_APPS_MCP_SERVER_NAME, - "calendar_search", - )]; - write_cached_codex_apps_tools(&cache_context, &cached_tools); - - let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ); - let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache"); - - assert_eq!(startup_tools.len(), 1); - assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); - assert_eq!(startup_tools[0].tool_name, "calendar_search"); - } - - #[tokio::test] - async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { - let startup_tools = vec![create_test_tool( - CODEX_APPS_MCP_SERVER_NAME, - "calendar_create_event", - )]; - let pending_client = - futures::future::pending::>() - .boxed() - .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); - manager.clients.insert( - CODEX_APPS_MCP_SERVER_NAME.to_string(), - AsyncManagedClient { - client: pending_client, - startup_snapshot: Some(startup_tools), - startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), - tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), - }, - ); - - let tools = manager.list_all_tools().await; - let tool = tools - .get("mcp__codex_apps__calendar_create_event") - .expect("tool from startup cache"); - assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); - assert_eq!(tool.tool_name, "calendar_create_event"); - } - - #[tokio::test] - async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() { - let pending_client = - futures::future::pending::>() - .boxed() - .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); - manager.clients.insert( - CODEX_APPS_MCP_SERVER_NAME.to_string(), - AsyncManagedClient { - client: pending_client, - startup_snapshot: None, - startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), - tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), - }, - ); - - let timeout_result = - tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await; - assert!(timeout_result.is_err()); - } - - #[tokio::test] - async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() { - let pending_client = - futures::future::pending::>() - .boxed() - .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); - manager.clients.insert( - CODEX_APPS_MCP_SERVER_NAME.to_string(), - AsyncManagedClient { - client: pending_client, - startup_snapshot: Some(Vec::new()), - startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), - tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), - }, - ); - - let timeout_result = - tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await; - let tools = timeout_result.expect("cache-hit startup snapshot should not block"); - assert!(tools.is_empty()); - } - - #[tokio::test] - async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { - let startup_tools = vec![create_test_tool( - CODEX_APPS_MCP_SERVER_NAME, - "calendar_create_event", - )]; - let failed_client = futures::future::ready::>( - Err(StartupOutcomeError::Failed { - error: "startup failed".to_string(), - }), - ) - .boxed() - .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); - let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true)); - manager.clients.insert( - CODEX_APPS_MCP_SERVER_NAME.to_string(), - AsyncManagedClient { - client: failed_client, - startup_snapshot: Some(startup_tools), - startup_complete, - tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), - }, - ); - - let tools = manager.list_all_tools().await; - let tool = tools - .get("mcp__codex_apps__calendar_create_event") - .expect("tool from startup cache"); - assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); - assert_eq!(tool.tool_name, "calendar_create_event"); - } - - #[test] - fn elicitation_capability_enabled_only_for_codex_apps() { - let codex_apps_capability = elicitation_capability_for_server(CODEX_APPS_MCP_SERVER_NAME); - assert!(matches!( - codex_apps_capability, - Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: None - }), - url: None, - }) - )); - - assert!(elicitation_capability_for_server("custom_mcp").is_none()); - } - - #[test] - fn mcp_init_error_display_prompts_for_github_pat() { - let server_name = "github"; - let entry = McpAuthStatusEntry { - config: McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://api.githubcopilot.com/mcp/".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - auth_status: McpAuthStatus::Unsupported, - }; - let err: StartupOutcomeError = anyhow::anyhow!("OAuth is unsupported").into(); - - let display = mcp_init_error_display(server_name, Some(&entry), &err); - - let expected = format!( - "GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN" - ); - - assert_eq!(expected, display); - } - - #[test] - fn mcp_init_error_display_prompts_for_login_when_auth_required() { - let server_name = "example"; - let err: StartupOutcomeError = anyhow::anyhow!("Auth required for server").into(); - - let display = mcp_init_error_display(server_name, None, &err); - - let expected = format!( - "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`." - ); - - assert_eq!(expected, display); - } - - #[test] - fn mcp_init_error_display_reports_generic_errors() { - let server_name = "custom"; - let entry = McpAuthStatusEntry { - config: McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://example.com".to_string(), - bearer_token_env_var: Some("TOKEN".to_string()), - http_headers: None, - env_http_headers: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - auth_status: McpAuthStatus::Unsupported, - }; - let err: StartupOutcomeError = anyhow::anyhow!("boom").into(); - - let display = mcp_init_error_display(server_name, Some(&entry), &err); - - let expected = format!("MCP client for `{server_name}` failed to start: {err:#}"); - - assert_eq!(expected, display); - } - - #[test] - fn mcp_init_error_display_includes_startup_timeout_hint() { - let server_name = "slow"; - let err: StartupOutcomeError = anyhow::anyhow!("request timed out").into(); - - let display = mcp_init_error_display(server_name, None, &err); - - assert_eq!( - "MCP client for `slow` timed out after 10 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX", - display - ); - } - - #[test] - fn transport_origin_extracts_http_origin() { - let transport = McpServerTransportConfig::StreamableHttp { - url: "https://example.com:8443/path?query=1".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }; - - assert_eq!( - transport_origin(&transport), - Some("https://example.com:8443".to_string()) - ); - } - - #[test] - fn transport_origin_is_stdio_for_stdio_transport() { - let transport = McpServerTransportConfig::Stdio { - command: "server".to_string(), - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }; - - assert_eq!(transport_origin(&transport), Some("stdio".to_string())); - } -} +#[path = "mcp_connection_manager_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/mcp_connection_manager_tests.rs b/codex-rs/core/src/mcp_connection_manager_tests.rs new file mode 100644 index 0000000000..51eaa67b17 --- /dev/null +++ b/codex-rs/core/src/mcp_connection_manager_tests.rs @@ -0,0 +1,644 @@ +use super::*; +use codex_protocol::protocol::McpAuthStatus; +use codex_protocol::protocol::RejectConfig; +use rmcp::model::JsonObject; +use std::collections::HashSet; +use std::sync::Arc; +use tempfile::tempdir; + +fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { + ToolInfo { + server_name: server_name.to_string(), + tool_name: tool_name.to_string(), + tool_namespace: if server_name == CODEX_APPS_MCP_SERVER_NAME { + format!("mcp__{server_name}__") + } else { + server_name.to_string() + }, + tool: Tool { + name: tool_name.to_string().into(), + title: None, + description: Some(format!("Test tool: {tool_name}").into()), + input_schema: Arc::new(JsonObject::default()), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: None, + connector_name: None, + plugin_display_names: Vec::new(), + connector_description: None, + } +} + +fn create_test_tool_with_connector( + server_name: &str, + tool_name: &str, + connector_id: &str, + connector_name: Option<&str>, +) -> ToolInfo { + let mut tool = create_test_tool(server_name, tool_name); + tool.connector_id = Some(connector_id.to_string()); + tool.connector_name = connector_name.map(ToOwned::to_owned); + tool +} + +fn create_codex_apps_tools_cache_context( + codex_home: PathBuf, + account_id: Option<&str>, + chatgpt_user_id: Option<&str>, +) -> CodexAppsToolsCacheContext { + CodexAppsToolsCacheContext { + codex_home, + user_key: CodexAppsToolsCacheKey { + account_id: account_id.map(ToOwned::to_owned), + chatgpt_user_id: chatgpt_user_id.map(ToOwned::to_owned), + is_workspace_account: false, + }, + } +} + +#[test] +fn elicitation_reject_policy_defaults_to_prompting() { + assert!(!elicitation_is_rejected_by_policy( + AskForApproval::OnFailure + )); + assert!(!elicitation_is_rejected_by_policy( + AskForApproval::OnRequest + )); + assert!(!elicitation_is_rejected_by_policy( + AskForApproval::UnlessTrusted + )); + assert!(!elicitation_is_rejected_by_policy(AskForApproval::Reject( + RejectConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + } + ))); +} + +#[test] +fn elicitation_reject_policy_respects_never_and_reject_config() { + assert!(elicitation_is_rejected_by_policy(AskForApproval::Never)); + assert!(elicitation_is_rejected_by_policy(AskForApproval::Reject( + RejectConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + } + ))); +} + +#[test] +fn test_qualify_tools_short_non_duplicated_names() { + let tools = vec![ + create_test_tool("server1", "tool1"), + create_test_tool("server1", "tool2"), + ]; + + let qualified_tools = qualify_tools(tools); + + assert_eq!(qualified_tools.len(), 2); + assert!(qualified_tools.contains_key("mcp__server1__tool1")); + assert!(qualified_tools.contains_key("mcp__server1__tool2")); +} + +#[test] +fn test_qualify_tools_duplicated_names_skipped() { + let tools = vec![ + create_test_tool("server1", "duplicate_tool"), + create_test_tool("server1", "duplicate_tool"), + ]; + + let qualified_tools = qualify_tools(tools); + + // Only the first tool should remain, the second is skipped + assert_eq!(qualified_tools.len(), 1); + assert!(qualified_tools.contains_key("mcp__server1__duplicate_tool")); +} + +#[test] +fn test_qualify_tools_long_names_same_server() { + let server_name = "my_server"; + + let tools = vec![ + create_test_tool( + server_name, + "extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits", + ), + create_test_tool( + server_name, + "yet_another_extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits", + ), + ]; + + let qualified_tools = qualify_tools(tools); + + assert_eq!(qualified_tools.len(), 2); + + let mut keys: Vec<_> = qualified_tools.keys().cloned().collect(); + keys.sort(); + + assert_eq!(keys[0].len(), 64); + assert_eq!( + keys[0], + "mcp__my_server__extremel119a2b97664e41363932dc84de21e2ff1b93b3e9" + ); + + assert_eq!(keys[1].len(), 64); + assert_eq!( + keys[1], + "mcp__my_server__yet_anot419a82a89325c1b477274a41f8c65ea5f3a7f341" + ); +} + +#[test] +fn test_qualify_tools_sanitizes_invalid_characters() { + let tools = vec![create_test_tool("server.one", "tool.two")]; + + let qualified_tools = qualify_tools(tools); + + assert_eq!(qualified_tools.len(), 1); + let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool"); + assert_eq!(qualified_name, "mcp__server_one__tool_two"); + + // The key is sanitized for OpenAI, but we keep original parts for the actual MCP call. + assert_eq!(tool.server_name, "server.one"); + assert_eq!(tool.tool_name, "tool.two"); + + assert!( + qualified_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'), + "qualified name must be Responses API compatible: {qualified_name:?}" + ); +} + +#[test] +fn tool_filter_allows_by_default() { + let filter = ToolFilter::default(); + + assert!(filter.allows("any")); +} + +#[test] +fn tool_filter_applies_enabled_list() { + let filter = ToolFilter { + enabled: Some(HashSet::from(["allowed".to_string()])), + disabled: HashSet::new(), + }; + + assert!(filter.allows("allowed")); + assert!(!filter.allows("denied")); +} + +#[test] +fn tool_filter_applies_disabled_list() { + let filter = ToolFilter { + enabled: None, + disabled: HashSet::from(["blocked".to_string()]), + }; + + assert!(!filter.allows("blocked")); + assert!(filter.allows("open")); +} + +#[test] +fn tool_filter_applies_enabled_then_disabled() { + let filter = ToolFilter { + enabled: Some(HashSet::from(["keep".to_string(), "remove".to_string()])), + disabled: HashSet::from(["remove".to_string()]), + }; + + assert!(filter.allows("keep")); + assert!(!filter.allows("remove")); + assert!(!filter.allows("unknown")); +} + +#[test] +fn filter_tools_applies_per_server_filters() { + let server1_tools = vec![ + create_test_tool("server1", "tool_a"), + create_test_tool("server1", "tool_b"), + ]; + let server2_tools = vec![create_test_tool("server2", "tool_a")]; + let server1_filter = ToolFilter { + enabled: Some(HashSet::from(["tool_a".to_string(), "tool_b".to_string()])), + disabled: HashSet::from(["tool_b".to_string()]), + }; + let server2_filter = ToolFilter { + enabled: None, + disabled: HashSet::from(["tool_a".to_string()]), + }; + + let filtered: Vec<_> = filter_tools(server1_tools, &server1_filter) + .into_iter() + .chain(filter_tools(server2_tools, &server2_filter)) + .collect(); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].server_name, "server1"); + assert_eq!(filtered[0].tool_name, "tool_a"); +} + +#[test] +fn codex_apps_tools_cache_is_overwritten_by_last_write() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; + let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; + + write_cached_codex_apps_tools(&cache_context, &tools_gateway_1); + let cached_gateway_1 = + read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for first write"); + assert_eq!(cached_gateway_1[0].tool_name, "one"); + + write_cached_codex_apps_tools(&cache_context, &tools_gateway_2); + let cached_gateway_2 = + read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for second write"); + assert_eq!(cached_gateway_2[0].tool_name, "two"); +} + +#[test] +fn codex_apps_tools_cache_is_scoped_per_user() { + let codex_home = tempdir().expect("tempdir"); + let cache_context_user_1 = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_context_user_2 = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-two"), + Some("user-two"), + ); + let tools_user_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; + let tools_user_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; + + write_cached_codex_apps_tools(&cache_context_user_1, &tools_user_1); + write_cached_codex_apps_tools(&cache_context_user_2, &tools_user_2); + + let read_user_1 = + read_cached_codex_apps_tools(&cache_context_user_1).expect("cache entry for user one"); + let read_user_2 = + read_cached_codex_apps_tools(&cache_context_user_2).expect("cache entry for user two"); + + assert_eq!(read_user_1[0].tool_name, "one"); + assert_eq!(read_user_2[0].tool_name, "two"); + assert_ne!( + cache_context_user_1.cache_path(), + cache_context_user_2.cache_path(), + "each user should get an isolated cache file" + ); +} + +#[test] +fn codex_apps_tools_cache_filters_disallowed_connectors() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let tools = vec![ + create_test_tool_with_connector( + CODEX_APPS_MCP_SERVER_NAME, + "blocked_tool", + "connector_openai_hidden", + Some("Hidden"), + ), + create_test_tool_with_connector( + CODEX_APPS_MCP_SERVER_NAME, + "allowed_tool", + "calendar", + Some("Calendar"), + ), + ]; + + write_cached_codex_apps_tools(&cache_context, &tools); + let cached = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user"); + + assert_eq!(cached.len(), 1); + assert_eq!(cached[0].tool_name, "allowed_tool"); + assert_eq!(cached[0].connector_id.as_deref(), Some("calendar")); +} + +#[test] +fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION + 1, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write"); + + assert!(read_cached_codex_apps_tools(&cache_context).is_none()); +} + +#[test] +fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + std::fs::write(cache_path, b"{not json").expect("write"); + + assert!(read_cached_codex_apps_tools(&cache_context).is_none()); +} + +#[test] +fn startup_cached_codex_apps_tools_loads_from_disk_cache() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cached_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_search", + )]; + write_cached_codex_apps_tools(&cache_context, &cached_tools); + + let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ); + let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache"); + + assert_eq!(startup_tools.len(), 1); + assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(startup_tools[0].tool_name, "calendar_search"); +} + +#[tokio::test] +async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { + let startup_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + )]; + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: Some(startup_tools), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + }, + ); + + let tools = manager.list_all_tools().await; + let tool = tools + .get("mcp__codex_apps__calendar_create_event") + .expect("tool from startup cache"); + assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(tool.tool_name, "calendar_create_event"); +} + +#[tokio::test] +async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() { + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: None, + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + }, + ); + + let timeout_result = + tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await; + assert!(timeout_result.is_err()); +} + +#[tokio::test] +async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() { + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: Some(Vec::new()), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + }, + ); + + let timeout_result = + tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await; + let tools = timeout_result.expect("cache-hit startup snapshot should not block"); + assert!(tools.is_empty()); +} + +#[tokio::test] +async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { + let startup_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + )]; + let failed_client = futures::future::ready::>(Err( + StartupOutcomeError::Failed { + error: "startup failed".to_string(), + }, + )) + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true)); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: failed_client, + startup_snapshot: Some(startup_tools), + startup_complete, + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + }, + ); + + let tools = manager.list_all_tools().await; + let tool = tools + .get("mcp__codex_apps__calendar_create_event") + .expect("tool from startup cache"); + assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(tool.tool_name, "calendar_create_event"); +} + +#[test] +fn elicitation_capability_enabled_only_for_codex_apps() { + let codex_apps_capability = elicitation_capability_for_server(CODEX_APPS_MCP_SERVER_NAME); + assert!(matches!( + codex_apps_capability, + Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: None + }), + url: None, + }) + )); + + assert!(elicitation_capability_for_server("custom_mcp").is_none()); +} + +#[test] +fn mcp_init_error_display_prompts_for_github_pat() { + let server_name = "github"; + let entry = McpAuthStatusEntry { + config: McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://api.githubcopilot.com/mcp/".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + auth_status: McpAuthStatus::Unsupported, + }; + let err: StartupOutcomeError = anyhow::anyhow!("OAuth is unsupported").into(); + + let display = mcp_init_error_display(server_name, Some(&entry), &err); + + let expected = format!( + "GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN" + ); + + assert_eq!(expected, display); +} + +#[test] +fn mcp_init_error_display_prompts_for_login_when_auth_required() { + let server_name = "example"; + let err: StartupOutcomeError = anyhow::anyhow!("Auth required for server").into(); + + let display = mcp_init_error_display(server_name, None, &err); + + let expected = format!( + "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`." + ); + + assert_eq!(expected, display); +} + +#[test] +fn mcp_init_error_display_reports_generic_errors() { + let server_name = "custom"; + let entry = McpAuthStatusEntry { + config: McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com".to_string(), + bearer_token_env_var: Some("TOKEN".to_string()), + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + auth_status: McpAuthStatus::Unsupported, + }; + let err: StartupOutcomeError = anyhow::anyhow!("boom").into(); + + let display = mcp_init_error_display(server_name, Some(&entry), &err); + + let expected = format!("MCP client for `{server_name}` failed to start: {err:#}"); + + assert_eq!(expected, display); +} + +#[test] +fn mcp_init_error_display_includes_startup_timeout_hint() { + let server_name = "slow"; + let err: StartupOutcomeError = anyhow::anyhow!("request timed out").into(); + + let display = mcp_init_error_display(server_name, None, &err); + + assert_eq!( + "MCP client for `slow` timed out after 10 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX", + display + ); +} + +#[test] +fn transport_origin_extracts_http_origin() { + let transport = McpServerTransportConfig::StreamableHttp { + url: "https://example.com:8443/path?query=1".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }; + + assert_eq!( + transport_origin(&transport), + Some("https://example.com:8443".to_string()) + ); +} + +#[test] +fn transport_origin_is_stdio_for_stdio_transport() { + let transport = McpServerTransportConfig::Stdio { + command: "server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }; + + assert_eq!(transport_origin(&transport), Some("stdio".to_string())); +} diff --git a/codex-rs/core/src/mcp_tool_approval_templates.rs b/codex-rs/core/src/mcp_tool_approval_templates.rs new file mode 100644 index 0000000000..f8fbad3ede --- /dev/null +++ b/codex-rs/core/src/mcp_tool_approval_templates.rs @@ -0,0 +1,365 @@ +use std::collections::HashSet; +use std::sync::LazyLock; + +use serde::Deserialize; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value; +use tracing::warn; + +const CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES_SCHEMA_VERSION: u8 = 4; +const CONNECTOR_NAME_TEMPLATE_VAR: &str = "{connector_name}"; + +static CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES: LazyLock< + Option>, +> = LazyLock::new(load_consequential_tool_message_templates); + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct RenderedMcpToolApprovalTemplate { + pub(crate) question: String, + pub(crate) elicitation_message: String, + pub(crate) tool_params: Option, + pub(crate) tool_params_display: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct RenderedMcpToolApprovalParam { + pub(crate) name: String, + pub(crate) value: Value, +} + +#[derive(Debug, Deserialize)] +struct ConsequentialToolMessageTemplatesFile { + schema_version: u8, + templates: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct ConsequentialToolMessageTemplate { + connector_id: String, + server_name: String, + tool_title: String, + template: String, + template_params: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct ConsequentialToolTemplateParam { + name: String, + label: String, +} + +pub(crate) fn render_mcp_tool_approval_template( + server_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, + tool_title: Option<&str>, + tool_params: Option<&Value>, +) -> Option { + let templates = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES.as_ref()?; + render_mcp_tool_approval_template_from_templates( + templates, + server_name, + connector_id, + connector_name, + tool_title, + tool_params, + ) +} + +fn load_consequential_tool_message_templates() -> Option> { + let templates = match serde_json::from_str::( + include_str!("consequential_tool_message_templates.json"), + ) { + Ok(templates) => templates, + Err(err) => { + warn!(error = %err, "failed to parse consequential tool approval templates"); + return None; + } + }; + + if templates.schema_version != CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES_SCHEMA_VERSION { + warn!( + found_schema_version = templates.schema_version, + expected_schema_version = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES_SCHEMA_VERSION, + "unexpected consequential tool approval templates schema version" + ); + return None; + } + + Some(templates.templates) +} + +fn render_mcp_tool_approval_template_from_templates( + templates: &[ConsequentialToolMessageTemplate], + server_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, + tool_title: Option<&str>, + tool_params: Option<&Value>, +) -> Option { + let connector_id = connector_id?; + let tool_title = tool_title.map(str::trim).filter(|name| !name.is_empty())?; + let template = templates.iter().find(|template| { + template.server_name == server_name + && template.connector_id == connector_id + && template.tool_title == tool_title + })?; + let elicitation_message = render_question_template(&template.template, connector_name)?; + let (tool_params, tool_params_display) = match tool_params { + Some(Value::Object(tool_params)) => { + render_tool_params(tool_params, &template.template_params)? + } + Some(_) => return None, + None => (None, Vec::new()), + }; + + Some(RenderedMcpToolApprovalTemplate { + question: elicitation_message.clone(), + elicitation_message, + tool_params, + tool_params_display, + }) +} + +fn render_question_template(template: &str, connector_name: Option<&str>) -> Option { + let template = template.trim(); + if template.is_empty() { + return None; + } + + if template.contains(CONNECTOR_NAME_TEMPLATE_VAR) { + let connector_name = connector_name + .map(str::trim) + .filter(|name| !name.is_empty())?; + return Some(template.replace(CONNECTOR_NAME_TEMPLATE_VAR, connector_name)); + } + + Some(template.to_string()) +} + +fn render_tool_params( + tool_params: &Map, + template_params: &[ConsequentialToolTemplateParam], +) -> Option<(Option, Vec)> { + let mut relabeled = Map::new(); + let mut display_params = Vec::new(); + let mut handled_names = HashSet::new(); + + for template_param in template_params { + let label = template_param.label.trim(); + if label.is_empty() { + return None; + } + let Some(value) = tool_params.get(&template_param.name) else { + continue; + }; + if relabeled.insert(label.to_string(), value.clone()).is_some() { + return None; + } + display_params.push(RenderedMcpToolApprovalParam { + name: label.to_string(), + value: value.clone(), + }); + handled_names.insert(template_param.name.as_str()); + } + + let mut remaining_params = tool_params + .iter() + .filter(|(name, _)| !handled_names.contains(name.as_str())) + .collect::>(); + remaining_params.sort_by(|(left_name, _), (right_name, _)| left_name.cmp(right_name)); + + for (name, value) in remaining_params { + if handled_names.contains(name.as_str()) { + continue; + } + if relabeled.insert(name.clone(), value.clone()).is_some() { + return None; + } + display_params.push(RenderedMcpToolApprovalParam { + name: name.clone(), + value: value.clone(), + }); + } + + Some((Some(Value::Object(relabeled)), display_params)) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::*; + + #[test] + fn renders_exact_match_with_readable_param_labels() { + let templates = vec![ConsequentialToolMessageTemplate { + connector_id: "calendar".to_string(), + server_name: "codex_apps".to_string(), + tool_title: "create_event".to_string(), + template: "Allow {connector_name} to create an event?".to_string(), + template_params: vec![ + ConsequentialToolTemplateParam { + name: "calendar_id".to_string(), + label: "Calendar".to_string(), + }, + ConsequentialToolTemplateParam { + name: "title".to_string(), + label: "Title".to_string(), + }, + ], + }]; + + let rendered = render_mcp_tool_approval_template_from_templates( + &templates, + "codex_apps", + Some("calendar"), + Some("Calendar"), + Some("create_event"), + Some(&json!({ + "title": "Roadmap review", + "calendar_id": "primary", + "timezone": "UTC", + })), + ); + + assert_eq!( + rendered, + Some(RenderedMcpToolApprovalTemplate { + question: "Allow Calendar to create an event?".to_string(), + elicitation_message: "Allow Calendar to create an event?".to_string(), + tool_params: Some(json!({ + "Calendar": "primary", + "Title": "Roadmap review", + "timezone": "UTC", + })), + tool_params_display: vec![ + RenderedMcpToolApprovalParam { + name: "Calendar".to_string(), + value: json!("primary"), + }, + RenderedMcpToolApprovalParam { + name: "Title".to_string(), + value: json!("Roadmap review"), + }, + RenderedMcpToolApprovalParam { + name: "timezone".to_string(), + value: json!("UTC"), + }, + ], + }) + ); + } + + #[test] + fn returns_none_when_no_exact_match_exists() { + let templates = vec![ConsequentialToolMessageTemplate { + connector_id: "calendar".to_string(), + server_name: "codex_apps".to_string(), + tool_title: "create_event".to_string(), + template: "Allow {connector_name} to create an event?".to_string(), + template_params: Vec::new(), + }]; + + assert_eq!( + render_mcp_tool_approval_template_from_templates( + &templates, + "codex_apps", + Some("calendar"), + Some("Calendar"), + Some("delete_event"), + Some(&json!({})), + ), + None + ); + } + + #[test] + fn returns_none_when_relabeling_would_collide() { + let templates = vec![ConsequentialToolMessageTemplate { + connector_id: "calendar".to_string(), + server_name: "codex_apps".to_string(), + tool_title: "create_event".to_string(), + template: "Allow {connector_name} to create an event?".to_string(), + template_params: vec![ConsequentialToolTemplateParam { + name: "calendar_id".to_string(), + label: "timezone".to_string(), + }], + }]; + + assert_eq!( + render_mcp_tool_approval_template_from_templates( + &templates, + "codex_apps", + Some("calendar"), + Some("Calendar"), + Some("create_event"), + Some(&json!({ + "calendar_id": "primary", + "timezone": "UTC", + })), + ), + None + ); + } + + #[test] + fn bundled_templates_load() { + assert_eq!(CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES.is_some(), true); + } + + #[test] + fn renders_literal_template_without_connector_substitution() { + let templates = vec![ConsequentialToolMessageTemplate { + connector_id: "github".to_string(), + server_name: "codex_apps".to_string(), + tool_title: "add_comment".to_string(), + template: "Allow GitHub to add a comment to a pull request?".to_string(), + template_params: Vec::new(), + }]; + + let rendered = render_mcp_tool_approval_template_from_templates( + &templates, + "codex_apps", + Some("github"), + None, + Some("add_comment"), + Some(&json!({})), + ); + + assert_eq!( + rendered, + Some(RenderedMcpToolApprovalTemplate { + question: "Allow GitHub to add a comment to a pull request?".to_string(), + elicitation_message: "Allow GitHub to add a comment to a pull request?".to_string(), + tool_params: Some(json!({})), + tool_params_display: Vec::new(), + }) + ); + } + + #[test] + fn returns_none_when_connector_placeholder_has_no_value() { + let templates = vec![ConsequentialToolMessageTemplate { + connector_id: "calendar".to_string(), + server_name: "codex_apps".to_string(), + tool_title: "create_event".to_string(), + template: "Allow {connector_name} to create an event?".to_string(), + template_params: Vec::new(), + }]; + + assert_eq!( + render_mcp_tool_approval_template_from_templates( + &templates, + "codex_apps", + Some("calendar"), + None, + Some("create_event"), + Some(&json!({})), + ), + None + ); + } +} diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 70421ae3dd..7430312136 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -26,6 +26,8 @@ use crate::guardian::guardian_approval_request_to_json; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam; +use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; @@ -385,6 +387,16 @@ struct McpToolApprovalPromptOptions { allow_persistent_approval: bool, } +struct McpToolApprovalElicitationRequest<'a> { + server: &'a str, + metadata: Option<&'a McpToolApprovalMetadata>, + tool_params: Option<&'a serde_json::Value>, + tool_params_display: Option<&'a [RenderedMcpToolApprovalParam]>, + question: RequestUserInputQuestion, + message_override: Option<&'a str>, + prompt_options: McpToolApprovalPromptOptions, +} + const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval"; const MCP_TOOL_APPROVAL_ACCEPT: &str = "Allow"; const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this session"; @@ -403,6 +415,7 @@ const MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: &str = "connector_description const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: &str = "tool_title"; const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: &str = "tool_description"; const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params"; +const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display"; #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct McpToolApprovalKey { @@ -503,14 +516,26 @@ async fn maybe_request_mcp_tool_approval( tool_call_mcp_elicitation_enabled, ); let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}"); + let rendered_template = render_mcp_tool_approval_template( + &invocation.server, + metadata.and_then(|metadata| metadata.connector_id.as_deref()), + metadata.and_then(|metadata| metadata.connector_name.as_deref()), + metadata.and_then(|metadata| metadata.tool_title.as_deref()), + invocation.arguments.as_ref(), + ); + let tool_params_display = rendered_template + .as_ref() + .map(|rendered_template| rendered_template.tool_params_display.clone()) + .or_else(|| build_mcp_tool_approval_display_params(invocation.arguments.as_ref())); let mut question = build_mcp_tool_approval_question( question_id.clone(), &invocation.server, &invocation.tool, - metadata.and_then(|metadata| metadata.tool_title.as_deref()), metadata.and_then(|metadata| metadata.connector_name.as_deref()), - annotations, prompt_options, + rendered_template + .as_ref() + .map(|rendered_template| rendered_template.question.as_str()), ); question.question = mcp_tool_approval_question_text(question.question, monitor_reason.as_deref()); @@ -521,11 +546,22 @@ async fn maybe_request_mcp_tool_approval( let params = build_mcp_tool_approval_elicitation_request( sess.as_ref(), turn_context.as_ref(), - &invocation.server, - metadata, - invocation.arguments.as_ref(), - question.clone(), - prompt_options, + McpToolApprovalElicitationRequest { + server: &invocation.server, + metadata, + tool_params: rendered_template + .as_ref() + .and_then(|rendered_template| rendered_template.tool_params.as_ref()) + .or(invocation.arguments.as_ref()), + tool_params_display: tool_params_display.as_deref(), + question, + message_override: rendered_template.as_ref().and_then(|rendered_template| { + monitor_reason + .is_none() + .then_some(rendered_template.elicitation_message.as_str()) + }), + prompt_options, + }, ); let decision = parse_mcp_tool_approval_elicitation_response( sess.request_mcp_server_elicitation(turn_context.as_ref(), request_id, params) @@ -674,7 +710,7 @@ async fn lookup_mcp_tool_metadata( let tool_info = tools .into_values() - .find(|tool_info| tool_info.server_name == server && tool_info.tool_name == tool_name)?; + .find(|tool_info| tool_info.server_name == server && tool_info.tool.name == tool_name)?; let connector_description = if server == CODEX_APPS_MCP_SERVER_NAME { let connectors = match connectors::list_cached_accessible_connectors_from_mcp_tools( turn_context.config.as_ref(), @@ -723,7 +759,7 @@ async fn lookup_mcp_app_usage_metadata( .await; tools.into_values().find_map(|tool_info| { - if tool_info.server_name == server && tool_info.tool_name == tool_name { + if tool_info.server_name == server && tool_info.tool.name == tool_name { Some(McpAppUsageMetadata { connector_id: tool_info.connector_id, app_name: tool_info.connector_name, @@ -738,34 +774,16 @@ fn build_mcp_tool_approval_question( question_id: String, server: &str, tool_name: &str, - tool_title: Option<&str>, connector_name: Option<&str>, - annotations: Option<&ToolAnnotations>, prompt_options: McpToolApprovalPromptOptions, + question_override: Option<&str>, ) -> RequestUserInputQuestion { - let destructive = - annotations.and_then(|annotations| annotations.destructive_hint) == Some(true); - let open_world = annotations.and_then(|annotations| annotations.open_world_hint) == Some(true); - let reason = match (destructive, open_world) { - (true, true) => "may modify data and access external systems", - (true, false) => "may modify or delete data", - (false, true) => "may access external systems", - (false, false) => "may have side effects", - }; - - let tool_label = tool_title.unwrap_or(tool_name); - let app_label = connector_name - .map(|name| format!("The {name} app")) + let question = question_override + .map(ToString::to_string) .unwrap_or_else(|| { - if server == CODEX_APPS_MCP_SERVER_NAME { - "This app".to_string() - } else { - format!("The {server} MCP server") - } + build_mcp_tool_approval_fallback_message(server, tool_name, connector_name) }); - let question = format!( - "{app_label} wants to run the tool \"{tool_label}\", which {reason}. Allow this action?" - ); + let question = format!("{}?", question.trim_end_matches('?')); let mut options = vec![RequestUserInputQuestionOption { label: MCP_TOOL_APPROVAL_ACCEPT.to_string(), @@ -798,6 +816,25 @@ fn build_mcp_tool_approval_question( } } +fn build_mcp_tool_approval_fallback_message( + server: &str, + tool_name: &str, + connector_name: Option<&str>, +) -> String { + let actor = connector_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| { + if server == CODEX_APPS_MCP_SERVER_NAME { + "this app".to_string() + } else { + format!("the {server} MCP server") + } + }); + format!("Allow {actor} to run tool \"{tool_name}\"?") +} + fn mcp_tool_approval_question_text(question: String, monitor_reason: Option<&str>) -> String { match monitor_reason.map(str::trim) { Some(reason) if !reason.is_empty() => { @@ -819,30 +856,24 @@ fn arc_monitor_interrupt_message(reason: &str) -> String { fn build_mcp_tool_approval_elicitation_request( sess: &Session, turn_context: &TurnContext, - server: &str, - metadata: Option<&McpToolApprovalMetadata>, - tool_params: Option<&serde_json::Value>, - question: RequestUserInputQuestion, - prompt_options: McpToolApprovalPromptOptions, + request: McpToolApprovalElicitationRequest<'_>, ) -> McpServerElicitationRequestParams { - let message = if question.header.trim().is_empty() { - question.question - } else { - let header = question.header; - let prompt = question.question; - format!("{header}\n\n{prompt}") - }; + let message = request + .message_override + .map(ToString::to_string) + .unwrap_or_else(|| request.question.question.clone()); McpServerElicitationRequestParams { thread_id: sess.conversation_id.to_string(), turn_id: Some(turn_context.sub_id.clone()), - server_name: server.to_string(), + server_name: request.server.to_string(), request: McpServerElicitationRequest::Form { meta: build_mcp_tool_approval_elicitation_meta( - server, - metadata, - tool_params, - prompt_options, + request.server, + request.metadata, + request.tool_params, + request.tool_params_display, + request.prompt_options, ), message, requested_schema: McpElicitationSchema { @@ -859,6 +890,7 @@ fn build_mcp_tool_approval_elicitation_meta( server: &str, metadata: Option<&McpToolApprovalMetadata>, tool_params: Option<&serde_json::Value>, + tool_params_display: Option<&[RenderedMcpToolApprovalParam]>, prompt_options: McpToolApprovalPromptOptions, ) -> Option { let mut meta = serde_json::Map::new(); @@ -941,9 +973,34 @@ fn build_mcp_tool_approval_elicitation_meta( tool_params.clone(), ); } + if let Some(tool_params_display) = tool_params_display + && let Ok(tool_params_display) = serde_json::to_value(tool_params_display) + { + meta.insert( + MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY.to_string(), + tool_params_display, + ); + } (!meta.is_empty()).then_some(serde_json::Value::Object(meta)) } +fn build_mcp_tool_approval_display_params( + tool_params: Option<&serde_json::Value>, +) -> Option> { + let tool_params = tool_params?.as_object()?; + let mut display_params = tool_params + .iter() + .map( + |(name, value)| crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam { + name: name.clone(), + value: value.clone(), + }, + ) + .collect::>(); + display_params.sort_by(|left, right| left.name.cmp(&right.name)); + Some(display_params) +} + fn parse_mcp_tool_approval_elicitation_response( response: Option, question_id: &str, @@ -1177,852 +1234,5 @@ async fn notify_mcp_tool_call_skip( } #[cfg(test)] -mod tests { - use super::*; - use crate::codex::make_session_and_context; - use crate::config::ConfigToml; - use crate::config::types::AppConfig; - use crate::config::types::AppToolConfig; - use crate::config::types::AppToolsConfig; - use crate::config::types::AppsConfigToml; - use codex_config::CONFIG_TOML_FILE; - use pretty_assertions::assert_eq; - use serde::Deserialize; - use std::collections::HashMap; - use std::sync::Arc; - use tempfile::tempdir; - - fn annotations( - read_only: Option, - destructive: Option, - open_world: Option, - ) -> ToolAnnotations { - ToolAnnotations { - destructive_hint: destructive, - idempotent_hint: None, - open_world_hint: open_world, - read_only_hint: read_only, - title: None, - } - } - - fn approval_metadata( - connector_id: Option<&str>, - connector_name: Option<&str>, - connector_description: Option<&str>, - tool_title: Option<&str>, - tool_description: Option<&str>, - ) -> McpToolApprovalMetadata { - McpToolApprovalMetadata { - annotations: None, - connector_id: connector_id.map(str::to_string), - connector_name: connector_name.map(str::to_string), - connector_description: connector_description.map(str::to_string), - tool_title: tool_title.map(str::to_string), - tool_description: tool_description.map(str::to_string), - } - } - - fn prompt_options( - allow_session_remember: bool, - allow_persistent_approval: bool, - ) -> McpToolApprovalPromptOptions { - McpToolApprovalPromptOptions { - allow_session_remember, - allow_persistent_approval, - } - } - - #[test] - fn approval_required_when_read_only_false_and_destructive() { - let annotations = annotations(Some(false), Some(true), None); - assert_eq!(requires_mcp_tool_approval(&annotations), true); - } - - #[test] - fn approval_required_when_read_only_false_and_open_world() { - let annotations = annotations(Some(false), None, Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), true); - } - - #[test] - fn approval_required_when_destructive_even_if_read_only_true() { - let annotations = annotations(Some(true), Some(true), Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), true); - } - - #[test] - fn prompt_mode_does_not_allow_persistent_remember() { - assert_eq!( - normalize_approval_decision_for_mode( - McpToolApprovalDecision::AcceptForSession, - AppToolApproval::Prompt, - ), - McpToolApprovalDecision::Accept - ); - assert_eq!( - normalize_approval_decision_for_mode( - McpToolApprovalDecision::AcceptAndRemember, - AppToolApproval::Prompt, - ), - McpToolApprovalDecision::Accept - ); - } - - #[test] - fn approval_question_text_prepends_safety_reason() { - assert_eq!( - mcp_tool_approval_question_text( - "Allow this action?".to_string(), - Some("This tool may contact an external system."), - ), - "Tool call needs your approval. Reason: This tool may contact an external system." - ); - } - - #[test] - fn custom_mcp_tool_question_mentions_server_name() { - let question = build_mcp_tool_approval_question( - "q".to_string(), - "custom_server", - "run_action", - Some("Run Action"), - None, - Some(&annotations(Some(false), Some(true), None)), - prompt_options(false, false), - ); - - assert_eq!(question.header, "Approve app tool call?"); - assert_eq!( - question.question, - "The custom_server MCP server wants to run the tool \"Run Action\", which may modify or delete data. Allow this action?" - ); - assert!( - !question - .options - .expect("options") - .into_iter() - .map(|option| option.label) - .any(|label| label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER) - ); - } - - #[test] - fn codex_apps_tool_question_keeps_legacy_app_label() { - let question = build_mcp_tool_approval_question( - "q".to_string(), - CODEX_APPS_MCP_SERVER_NAME, - "run_action", - Some("Run Action"), - None, - Some(&annotations(Some(false), Some(true), None)), - prompt_options(true, true), - ); - - assert!( - question - .question - .starts_with("This app wants to run the tool \"Run Action\"") - ); - } - - #[test] - fn trusted_codex_apps_tool_question_offers_always_allow() { - let question = build_mcp_tool_approval_question( - "q".to_string(), - CODEX_APPS_MCP_SERVER_NAME, - "run_action", - Some("Run Action"), - Some("Calendar"), - Some(&annotations(Some(false), Some(true), None)), - prompt_options(true, true), - ); - let options = question.options.expect("options"); - - assert!(options.iter().any(|option| { - option.label == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION - && option.description == "Run the tool and remember this choice for this session." - })); - assert!(options.iter().any(|option| { - option.label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER - && option.description - == "Run the tool and remember this choice for future tool calls." - })); - assert_eq!( - options - .into_iter() - .map(|option| option.label) - .collect::>(), - vec![ - MCP_TOOL_APPROVAL_ACCEPT.to_string(), - MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(), - MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(), - MCP_TOOL_APPROVAL_CANCEL.to_string(), - ] - ); - } - - #[test] - fn codex_apps_tool_question_without_elicitation_omits_always_allow() { - let session_key = McpToolApprovalKey { - server: CODEX_APPS_MCP_SERVER_NAME.to_string(), - connector_id: Some("calendar".to_string()), - tool_name: "run_action".to_string(), - }; - let persistent_key = session_key.clone(); - let question = build_mcp_tool_approval_question( - "q".to_string(), - CODEX_APPS_MCP_SERVER_NAME, - "run_action", - Some("Run Action"), - Some("Calendar"), - Some(&annotations(Some(false), Some(true), None)), - mcp_tool_approval_prompt_options(Some(&session_key), Some(&persistent_key), false), - ); - - assert_eq!( - question - .options - .expect("options") - .into_iter() - .map(|option| option.label) - .collect::>(), - vec![ - MCP_TOOL_APPROVAL_ACCEPT.to_string(), - MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(), - MCP_TOOL_APPROVAL_CANCEL.to_string(), - ] - ); - } - - #[test] - fn custom_mcp_tool_question_offers_session_remember_without_always_allow() { - let question = build_mcp_tool_approval_question( - "q".to_string(), - "custom_server", - "run_action", - Some("Run Action"), - None, - Some(&annotations(Some(false), Some(true), None)), - prompt_options(true, false), - ); - - assert_eq!( - question - .options - .expect("options") - .into_iter() - .map(|option| option.label) - .collect::>(), - vec![ - MCP_TOOL_APPROVAL_ACCEPT.to_string(), - MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(), - MCP_TOOL_APPROVAL_CANCEL.to_string(), - ] - ); - } - - #[test] - fn custom_servers_keep_session_remember_without_persistent_approval() { - let invocation = McpInvocation { - server: "custom_server".to_string(), - tool: "run_action".to_string(), - arguments: None, - }; - let expected = McpToolApprovalKey { - server: "custom_server".to_string(), - connector_id: None, - tool_name: "run_action".to_string(), - }; - - assert_eq!( - session_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto), - Some(expected) - ); - assert_eq!( - persistent_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto), - None - ); - } - - #[test] - fn codex_apps_connectors_support_persistent_approval() { - let invocation = McpInvocation { - server: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool: "calendar/list_events".to_string(), - arguments: None, - }; - let metadata = approval_metadata(Some("calendar"), Some("Calendar"), None, None, None); - let expected = McpToolApprovalKey { - server: CODEX_APPS_MCP_SERVER_NAME.to_string(), - connector_id: Some("calendar".to_string()), - tool_name: "calendar/list_events".to_string(), - }; - - assert_eq!( - session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto), - Some(expected.clone()) - ); - assert_eq!( - persistent_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto), - Some(expected) - ); - } - - #[test] - fn sanitize_mcp_tool_result_for_model_rewrites_image_content() { - let result = Ok(CallToolResult { - content: vec![ - serde_json::json!({ - "type": "image", - "data": "Zm9v", - "mimeType": "image/png", - }), - serde_json::json!({ - "type": "text", - "text": "hello", - }), - ], - structured_content: None, - is_error: Some(false), - meta: None, - }); - - let got = sanitize_mcp_tool_result_for_model(false, result).expect("sanitized result"); - - assert_eq!( - got.content, - vec![ - serde_json::json!({ - "type": "text", - "text": "", - }), - serde_json::json!({ - "type": "text", - "text": "hello", - }), - ] - ); - } - - #[test] - fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() { - let original = CallToolResult { - content: vec![serde_json::json!({ - "type": "image", - "data": "Zm9v", - "mimeType": "image/png", - })], - structured_content: Some(serde_json::json!({"x": 1})), - is_error: Some(false), - meta: Some(serde_json::json!({"k": "v"})), - }; - - let got = sanitize_mcp_tool_result_for_model(true, Ok(original.clone())) - .expect("unsanitized result"); - - assert_eq!(got, original); - } - - #[test] - fn accepted_elicitation_content_converts_to_request_user_input_response() { - let response = - request_user_input_response_from_elicitation_content(Some(serde_json::json!( - { - "approval": MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER, - } - ))); - - assert_eq!( - response, - Some(RequestUserInputResponse { - answers: std::collections::HashMap::from([( - "approval".to_string(), - RequestUserInputAnswer { - answers: vec![MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string()], - }, - )]), - }) - ); - } - - #[test] - fn approval_elicitation_meta_marks_tool_approvals() { - assert_eq!( - build_mcp_tool_approval_elicitation_meta( - "custom_server", - None, - None, - prompt_options(false, false), - ), - Some(serde_json::json!({ - MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, - })) - ); - } - - #[test] - fn approval_elicitation_meta_keeps_session_persist_behavior_for_custom_servers() { - assert_eq!( - build_mcp_tool_approval_elicitation_meta( - "custom_server", - Some(&approval_metadata( - None, - None, - None, - Some("Run Action"), - Some("Runs the selected action."), - )), - Some(&serde_json::json!({"id": 1})), - prompt_options(true, false), - ), - Some(serde_json::json!({ - MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, - MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION, - MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action", - MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.", - MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { - "id": 1, - }, - })) - ); - } - - #[test] - fn guardian_mcp_review_request_includes_invocation_metadata() { - let invocation = McpInvocation { - server: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool: "browser_navigate".to_string(), - arguments: Some(serde_json::json!({ - "url": "https://example.com", - })), - }; - - let request = build_guardian_mcp_tool_review_request( - &invocation, - Some(&approval_metadata( - Some("playwright"), - Some("Playwright"), - Some("Browser automation"), - Some("Navigate"), - Some("Open a page"), - )), - ); - - assert_eq!( - request, - GuardianApprovalRequest::McpToolCall { - server: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool_name: "browser_navigate".to_string(), - arguments: Some(serde_json::json!({ - "url": "https://example.com", - })), - connector_id: Some("playwright".to_string()), - connector_name: Some("Playwright".to_string()), - connector_description: Some("Browser automation".to_string()), - tool_title: Some("Navigate".to_string()), - tool_description: Some("Open a page".to_string()), - annotations: None, - } - ); - } - - #[test] - fn guardian_mcp_review_request_includes_annotations_when_present() { - let invocation = McpInvocation { - server: "custom_server".to_string(), - tool: "dangerous_tool".to_string(), - arguments: None, - }; - let metadata = McpToolApprovalMetadata { - annotations: Some(annotations(Some(false), Some(true), Some(true))), - connector_id: None, - connector_name: None, - connector_description: None, - tool_title: None, - tool_description: None, - }; - - let request = build_guardian_mcp_tool_review_request(&invocation, Some(&metadata)); - - assert_eq!( - request, - GuardianApprovalRequest::McpToolCall { - server: "custom_server".to_string(), - tool_name: "dangerous_tool".to_string(), - arguments: None, - connector_id: None, - connector_name: None, - connector_description: None, - tool_title: None, - tool_description: None, - annotations: Some(GuardianMcpAnnotations { - destructive_hint: Some(true), - open_world_hint: Some(true), - read_only_hint: Some(false), - }), - } - ); - } - - #[test] - fn prepare_arc_request_action_serializes_mcp_tool_call_shape() { - let invocation = McpInvocation { - server: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool: "browser_navigate".to_string(), - arguments: Some(serde_json::json!({ - "url": "https://example.com", - })), - }; - - let action = prepare_arc_request_action( - &invocation, - Some(&approval_metadata( - None, - Some("Playwright"), - None, - Some("Navigate"), - None, - )), - ); - - assert_eq!( - action, - serde_json::json!({ - "tool": "mcp_tool_call", - "server": CODEX_APPS_MCP_SERVER_NAME, - "tool_name": "browser_navigate", - "arguments": { - "url": "https://example.com", - }, - "connector_name": "Playwright", - "tool_title": "Navigate", - }) - ); - } - - #[test] - fn guardian_review_decision_maps_to_mcp_tool_decision() { - assert_eq!( - mcp_tool_approval_decision_from_guardian(ReviewDecision::Approved), - McpToolApprovalDecision::Accept - ); - assert_eq!( - mcp_tool_approval_decision_from_guardian(ReviewDecision::Denied), - McpToolApprovalDecision::Decline - ); - assert_eq!( - mcp_tool_approval_decision_from_guardian(ReviewDecision::Abort), - McpToolApprovalDecision::Decline - ); - } - - #[test] - fn approval_elicitation_meta_includes_connector_source_for_codex_apps() { - assert_eq!( - build_mcp_tool_approval_elicitation_meta( - CODEX_APPS_MCP_SERVER_NAME, - Some(&approval_metadata( - Some("calendar"), - Some("Calendar"), - Some("Manage events and schedules."), - Some("Run Action"), - Some("Runs the selected action."), - )), - Some(&serde_json::json!({ - "calendar_id": "primary", - })), - prompt_options(false, false), - ), - Some(serde_json::json!({ - MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, - MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR, - MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar", - MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar", - MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.", - MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action", - MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.", - MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { - "calendar_id": "primary", - }, - })) - ); - } - - #[test] - fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_source() { - assert_eq!( - build_mcp_tool_approval_elicitation_meta( - CODEX_APPS_MCP_SERVER_NAME, - Some(&approval_metadata( - Some("calendar"), - Some("Calendar"), - Some("Manage events and schedules."), - Some("Run Action"), - Some("Runs the selected action."), - )), - Some(&serde_json::json!({ - "calendar_id": "primary", - })), - prompt_options(true, true), - ), - Some(serde_json::json!({ - MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, - MCP_TOOL_APPROVAL_PERSIST_KEY: [ - MCP_TOOL_APPROVAL_PERSIST_SESSION, - MCP_TOOL_APPROVAL_PERSIST_ALWAYS, - ], - MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR, - MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar", - MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar", - MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.", - MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action", - MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.", - MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { - "calendar_id": "primary", - }, - })) - ); - } - - #[test] - fn declined_elicitation_response_stays_decline() { - let response = parse_mcp_tool_approval_elicitation_response( - Some(ElicitationResponse { - action: ElicitationAction::Decline, - content: Some(serde_json::json!({ - "approval": MCP_TOOL_APPROVAL_ACCEPT, - })), - meta: None, - }), - "approval", - ); - - assert_eq!(response, McpToolApprovalDecision::Decline); - } - - #[test] - fn accepted_elicitation_response_uses_always_persist_meta() { - let response = parse_mcp_tool_approval_elicitation_response( - Some(ElicitationResponse { - action: ElicitationAction::Accept, - content: None, - meta: Some(serde_json::json!({ - MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_ALWAYS, - })), - }), - "approval", - ); - - assert_eq!(response, McpToolApprovalDecision::AcceptAndRemember); - } - - #[test] - fn accepted_elicitation_response_uses_session_persist_meta() { - let response = parse_mcp_tool_approval_elicitation_response( - Some(ElicitationResponse { - action: ElicitationAction::Accept, - content: None, - meta: Some(serde_json::json!({ - MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION, - })), - }), - "approval", - ); - - assert_eq!(response, McpToolApprovalDecision::AcceptForSession); - } - - #[test] - fn accepted_elicitation_without_content_defaults_to_accept() { - let response = parse_mcp_tool_approval_elicitation_response( - Some(ElicitationResponse { - action: ElicitationAction::Accept, - content: None, - meta: None, - }), - "approval", - ); - - assert_eq!(response, McpToolApprovalDecision::Accept); - } - - #[tokio::test] - async fn persist_codex_app_tool_approval_writes_tool_override() { - let tmp = tempdir().expect("tempdir"); - - persist_codex_app_tool_approval(tmp.path(), "calendar", "calendar/list_events") - .await - .expect("persist approval"); - - let contents = - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); - let parsed: ConfigToml = toml::from_str(&contents).expect("parse config"); - - assert_eq!( - parsed.apps, - Some(AppsConfigToml { - default: None, - apps: HashMap::from([( - "calendar".to_string(), - AppConfig { - enabled: true, - destructive_enabled: None, - open_world_enabled: None, - default_tools_approval_mode: None, - default_tools_enabled: None, - tools: Some(AppToolsConfig { - tools: HashMap::from([( - "calendar/list_events".to_string(), - AppToolConfig { - enabled: None, - approval_mode: Some(AppToolApproval::Approve), - }, - )]), - }), - }, - )]), - }) - ); - assert!(contents.contains("[apps.calendar.tools.\"calendar/list_events\"]")); - } - - #[tokio::test] - async fn maybe_persist_mcp_tool_approval_reloads_session_config() { - let (session, turn_context) = make_session_and_context().await; - let codex_home = session.codex_home().await; - std::fs::create_dir_all(&codex_home).expect("create codex home"); - let key = McpToolApprovalKey { - server: CODEX_APPS_MCP_SERVER_NAME.to_string(), - connector_id: Some("calendar".to_string()), - tool_name: "calendar/list_events".to_string(), - }; - - maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await; - - let config = session.get_config().await; - let apps_toml = config - .config_layer_stack - .effective_config() - .as_table() - .and_then(|table| table.get("apps")) - .cloned() - .expect("apps table"); - let apps = AppsConfigToml::deserialize(apps_toml).expect("deserialize apps config"); - let tool = apps - .apps - .get("calendar") - .and_then(|app| app.tools.as_ref()) - .and_then(|tools| tools.tools.get("calendar/list_events")) - .expect("calendar/list_events tool config exists"); - - assert_eq!( - tool, - &AppToolConfig { - enabled: None, - approval_mode: Some(AppToolApproval::Approve), - } - ); - assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true); - } - - #[tokio::test] - async fn approve_mode_skips_when_annotations_do_not_require_approval() { - let (session, turn_context) = make_session_and_context().await; - let session = Arc::new(session); - let turn_context = Arc::new(turn_context); - let invocation = McpInvocation { - server: "custom_server".to_string(), - tool: "read_only_tool".to_string(), - arguments: None, - }; - let metadata = McpToolApprovalMetadata { - annotations: Some(annotations(Some(true), None, None)), - connector_id: None, - connector_name: None, - connector_description: None, - tool_title: Some("Read Only Tool".to_string()), - tool_description: None, - }; - - let decision = maybe_request_mcp_tool_approval( - &session, - &turn_context, - "call-1", - &invocation, - Some(&metadata), - AppToolApproval::Approve, - ) - .await; - - assert_eq!(decision, None); - } - - #[tokio::test] - async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - - 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", - "short_reason": "needs approval", - "rationale": "high-risk action", - "risk_score": 96, - "risk_level": "critical", - "evidence": [{ - "message": "dangerous_tool", - "why": "high-risk action", - }], - }))) - .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); - - let session = Arc::new(session); - let turn_context = Arc::new(turn_context); - let invocation = McpInvocation { - server: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool: "dangerous_tool".to_string(), - arguments: Some(serde_json::json!({ "id": 1 })), - }; - let metadata = McpToolApprovalMetadata { - annotations: Some(annotations(Some(false), Some(true), Some(true))), - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - connector_description: Some("Manage events".to_string()), - tool_title: Some("Dangerous Tool".to_string()), - tool_description: Some("Performs a risky action.".to_string()), - }; - - let decision = maybe_request_mcp_tool_approval( - &session, - &turn_context, - "call-2", - &invocation, - Some(&metadata), - AppToolApproval::Approve, - ) - .await; - - assert_eq!( - decision, - Some(McpToolApprovalDecision::BlockedBySafetyMonitor( - "Tool call was cancelled because of safety risks: high-risk action".to_string(), - )) - ); - } -} +#[path = "mcp_tool_call_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs new file mode 100644 index 0000000000..5e8cb7f873 --- /dev/null +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -0,0 +1,913 @@ +use super::*; +use crate::codex::make_session_and_context; +use crate::config::ConfigToml; +use crate::config::types::AppConfig; +use crate::config::types::AppToolConfig; +use crate::config::types::AppToolsConfig; +use crate::config::types::AppsConfigToml; +use codex_config::CONFIG_TOML_FILE; +use pretty_assertions::assert_eq; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::Arc; +use tempfile::tempdir; + +fn annotations( + read_only: Option, + destructive: Option, + open_world: Option, +) -> ToolAnnotations { + ToolAnnotations { + destructive_hint: destructive, + idempotent_hint: None, + open_world_hint: open_world, + read_only_hint: read_only, + title: None, + } +} + +fn approval_metadata( + connector_id: Option<&str>, + connector_name: Option<&str>, + connector_description: Option<&str>, + tool_title: Option<&str>, + tool_description: Option<&str>, +) -> McpToolApprovalMetadata { + McpToolApprovalMetadata { + annotations: None, + connector_id: connector_id.map(str::to_string), + connector_name: connector_name.map(str::to_string), + connector_description: connector_description.map(str::to_string), + tool_title: tool_title.map(str::to_string), + tool_description: tool_description.map(str::to_string), + } +} + +fn prompt_options( + allow_session_remember: bool, + allow_persistent_approval: bool, +) -> McpToolApprovalPromptOptions { + McpToolApprovalPromptOptions { + allow_session_remember, + allow_persistent_approval, + } +} + +#[test] +fn approval_required_when_read_only_false_and_destructive() { + let annotations = annotations(Some(false), Some(true), None); + assert_eq!(requires_mcp_tool_approval(&annotations), true); +} + +#[test] +fn approval_required_when_read_only_false_and_open_world() { + let annotations = annotations(Some(false), None, Some(true)); + assert_eq!(requires_mcp_tool_approval(&annotations), true); +} + +#[test] +fn approval_required_when_destructive_even_if_read_only_true() { + let annotations = annotations(Some(true), Some(true), Some(true)); + assert_eq!(requires_mcp_tool_approval(&annotations), true); +} + +#[test] +fn prompt_mode_does_not_allow_persistent_remember() { + assert_eq!( + normalize_approval_decision_for_mode( + McpToolApprovalDecision::AcceptForSession, + AppToolApproval::Prompt, + ), + McpToolApprovalDecision::Accept + ); + assert_eq!( + normalize_approval_decision_for_mode( + McpToolApprovalDecision::AcceptAndRemember, + AppToolApproval::Prompt, + ), + McpToolApprovalDecision::Accept + ); +} + +#[test] +fn approval_question_text_prepends_safety_reason() { + assert_eq!( + mcp_tool_approval_question_text( + "Allow this action?".to_string(), + Some("This tool may contact an external system."), + ), + "Tool call needs your approval. Reason: This tool may contact an external system." + ); +} + +#[tokio::test] +async fn approval_elicitation_request_uses_message_override_and_readable_tool_params() { + let (session, turn_context) = make_session_and_context().await; + let question = build_mcp_tool_approval_question( + "q".to_string(), + CODEX_APPS_MCP_SERVER_NAME, + "create_event", + Some("Calendar"), + prompt_options(true, true), + Some("Allow Calendar to create an event?"), + ); + + let request = build_mcp_tool_approval_elicitation_request( + &session, + &turn_context, + McpToolApprovalElicitationRequest { + server: CODEX_APPS_MCP_SERVER_NAME, + metadata: Some(&approval_metadata( + Some("calendar"), + Some("Calendar"), + Some("Manage events and schedules."), + Some("Create Event"), + Some("Create a calendar event."), + )), + tool_params: Some(&serde_json::json!({ + "Calendar": "primary", + "Title": "Roadmap review", + })), + tool_params_display: None, + question, + message_override: Some("Allow Calendar to create an event?"), + prompt_options: prompt_options(true, true), + }, + ); + + assert_eq!( + request, + McpServerElicitationRequestParams { + thread_id: session.conversation_id.to_string(), + turn_id: Some(turn_context.sub_id), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(serde_json::json!({ + MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, + MCP_TOOL_APPROVAL_PERSIST_KEY: [ + MCP_TOOL_APPROVAL_PERSIST_SESSION, + MCP_TOOL_APPROVAL_PERSIST_ALWAYS, + ], + MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR, + MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar", + MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar", + MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.", + MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Create Event", + MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Create a calendar event.", + MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { + "Calendar": "primary", + "Title": "Roadmap review", + }, + })), + message: "Allow Calendar to create an event?".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + } + ); +} + +#[test] +fn custom_mcp_tool_question_mentions_server_name() { + let question = build_mcp_tool_approval_question( + "q".to_string(), + "custom_server", + "run_action", + None, + prompt_options(false, false), + None, + ); + + assert_eq!(question.header, "Approve app tool call?"); + assert_eq!( + question.question, + "Allow the custom_server MCP server to run tool \"run_action\"?" + ); + assert!( + !question + .options + .expect("options") + .into_iter() + .map(|option| option.label) + .any(|label| label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER) + ); +} + +#[test] +fn codex_apps_tool_question_uses_fallback_app_label() { + let question = build_mcp_tool_approval_question( + "q".to_string(), + CODEX_APPS_MCP_SERVER_NAME, + "run_action", + None, + prompt_options(true, true), + None, + ); + + assert_eq!( + question.question, + "Allow this app to run tool \"run_action\"?" + ); +} + +#[test] +fn trusted_codex_apps_tool_question_offers_always_allow() { + let question = build_mcp_tool_approval_question( + "q".to_string(), + CODEX_APPS_MCP_SERVER_NAME, + "run_action", + Some("Calendar"), + prompt_options(true, true), + None, + ); + let options = question.options.expect("options"); + + assert!(options.iter().any(|option| { + option.label == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION + && option.description == "Run the tool and remember this choice for this session." + })); + assert!(options.iter().any(|option| { + option.label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER + && option.description == "Run the tool and remember this choice for future tool calls." + })); + assert_eq!( + options + .into_iter() + .map(|option| option.label) + .collect::>(), + vec![ + MCP_TOOL_APPROVAL_ACCEPT.to_string(), + MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(), + MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(), + MCP_TOOL_APPROVAL_CANCEL.to_string(), + ] + ); +} + +#[test] +fn codex_apps_tool_question_without_elicitation_omits_always_allow() { + let session_key = McpToolApprovalKey { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + connector_id: Some("calendar".to_string()), + tool_name: "run_action".to_string(), + }; + let persistent_key = session_key.clone(); + let question = build_mcp_tool_approval_question( + "q".to_string(), + CODEX_APPS_MCP_SERVER_NAME, + "run_action", + Some("Calendar"), + mcp_tool_approval_prompt_options(Some(&session_key), Some(&persistent_key), false), + None, + ); + + assert_eq!( + question + .options + .expect("options") + .into_iter() + .map(|option| option.label) + .collect::>(), + vec![ + MCP_TOOL_APPROVAL_ACCEPT.to_string(), + MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(), + MCP_TOOL_APPROVAL_CANCEL.to_string(), + ] + ); +} + +#[test] +fn custom_mcp_tool_question_offers_session_remember_without_always_allow() { + let question = build_mcp_tool_approval_question( + "q".to_string(), + "custom_server", + "run_action", + None, + prompt_options(true, false), + None, + ); + + assert_eq!( + question + .options + .expect("options") + .into_iter() + .map(|option| option.label) + .collect::>(), + vec![ + MCP_TOOL_APPROVAL_ACCEPT.to_string(), + MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(), + MCP_TOOL_APPROVAL_CANCEL.to_string(), + ] + ); +} + +#[test] +fn custom_servers_keep_session_remember_without_persistent_approval() { + let invocation = McpInvocation { + server: "custom_server".to_string(), + tool: "run_action".to_string(), + arguments: None, + }; + let expected = McpToolApprovalKey { + server: "custom_server".to_string(), + connector_id: None, + tool_name: "run_action".to_string(), + }; + + assert_eq!( + session_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto), + Some(expected) + ); + assert_eq!( + persistent_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto), + None + ); +} + +#[test] +fn codex_apps_connectors_support_persistent_approval() { + let invocation = McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "calendar/list_events".to_string(), + arguments: None, + }; + let metadata = approval_metadata(Some("calendar"), Some("Calendar"), None, None, None); + let expected = McpToolApprovalKey { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + connector_id: Some("calendar".to_string()), + tool_name: "calendar/list_events".to_string(), + }; + + assert_eq!( + session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto), + Some(expected.clone()) + ); + assert_eq!( + persistent_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto), + Some(expected) + ); +} + +#[test] +fn sanitize_mcp_tool_result_for_model_rewrites_image_content() { + let result = Ok(CallToolResult { + content: vec![ + serde_json::json!({ + "type": "image", + "data": "Zm9v", + "mimeType": "image/png", + }), + serde_json::json!({ + "type": "text", + "text": "hello", + }), + ], + structured_content: None, + is_error: Some(false), + meta: None, + }); + + let got = sanitize_mcp_tool_result_for_model(false, result).expect("sanitized result"); + + assert_eq!( + got.content, + vec![ + serde_json::json!({ + "type": "text", + "text": "", + }), + serde_json::json!({ + "type": "text", + "text": "hello", + }), + ] + ); +} + +#[test] +fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() { + let original = CallToolResult { + content: vec![serde_json::json!({ + "type": "image", + "data": "Zm9v", + "mimeType": "image/png", + })], + structured_content: Some(serde_json::json!({"x": 1})), + is_error: Some(false), + meta: Some(serde_json::json!({"k": "v"})), + }; + + let got = + sanitize_mcp_tool_result_for_model(true, Ok(original.clone())).expect("unsanitized result"); + + assert_eq!(got, original); +} + +#[test] +fn accepted_elicitation_content_converts_to_request_user_input_response() { + let response = request_user_input_response_from_elicitation_content(Some(serde_json::json!( + { + "approval": MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER, + } + ))); + + assert_eq!( + response, + Some(RequestUserInputResponse { + answers: std::collections::HashMap::from([( + "approval".to_string(), + RequestUserInputAnswer { + answers: vec![MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string()], + }, + )]), + }) + ); +} + +#[test] +fn approval_elicitation_meta_marks_tool_approvals() { + assert_eq!( + build_mcp_tool_approval_elicitation_meta( + "custom_server", + None, + None, + None, + prompt_options(false, false), + ), + Some(serde_json::json!({ + MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, + })) + ); +} + +#[test] +fn approval_elicitation_meta_keeps_session_persist_behavior_for_custom_servers() { + assert_eq!( + build_mcp_tool_approval_elicitation_meta( + "custom_server", + Some(&approval_metadata( + None, + None, + None, + Some("Run Action"), + Some("Runs the selected action."), + )), + Some(&serde_json::json!({"id": 1})), + None, + prompt_options(true, false), + ), + Some(serde_json::json!({ + MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, + MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION, + MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action", + MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.", + MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { + "id": 1, + }, + })) + ); +} + +#[test] +fn guardian_mcp_review_request_includes_invocation_metadata() { + let invocation = McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "browser_navigate".to_string(), + arguments: Some(serde_json::json!({ + "url": "https://example.com", + })), + }; + + let request = build_guardian_mcp_tool_review_request( + &invocation, + Some(&approval_metadata( + Some("playwright"), + Some("Playwright"), + Some("Browser automation"), + Some("Navigate"), + Some("Open a page"), + )), + ); + + assert_eq!( + request, + GuardianApprovalRequest::McpToolCall { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "browser_navigate".to_string(), + arguments: Some(serde_json::json!({ + "url": "https://example.com", + })), + connector_id: Some("playwright".to_string()), + connector_name: Some("Playwright".to_string()), + connector_description: Some("Browser automation".to_string()), + tool_title: Some("Navigate".to_string()), + tool_description: Some("Open a page".to_string()), + annotations: None, + } + ); +} + +#[test] +fn guardian_mcp_review_request_includes_annotations_when_present() { + let invocation = McpInvocation { + server: "custom_server".to_string(), + tool: "dangerous_tool".to_string(), + arguments: None, + }; + let metadata = McpToolApprovalMetadata { + annotations: Some(annotations(Some(false), Some(true), Some(true))), + connector_id: None, + connector_name: None, + connector_description: None, + tool_title: None, + tool_description: None, + }; + + let request = build_guardian_mcp_tool_review_request(&invocation, Some(&metadata)); + + assert_eq!( + request, + GuardianApprovalRequest::McpToolCall { + server: "custom_server".to_string(), + tool_name: "dangerous_tool".to_string(), + arguments: None, + connector_id: None, + connector_name: None, + connector_description: None, + tool_title: None, + tool_description: None, + annotations: Some(GuardianMcpAnnotations { + destructive_hint: Some(true), + open_world_hint: Some(true), + read_only_hint: Some(false), + }), + } + ); +} + +#[test] +fn prepare_arc_request_action_serializes_mcp_tool_call_shape() { + let invocation = McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "browser_navigate".to_string(), + arguments: Some(serde_json::json!({ + "url": "https://example.com", + })), + }; + + let action = prepare_arc_request_action( + &invocation, + Some(&approval_metadata( + None, + Some("Playwright"), + None, + Some("Navigate"), + None, + )), + ); + + assert_eq!( + action, + serde_json::json!({ + "tool": "mcp_tool_call", + "server": CODEX_APPS_MCP_SERVER_NAME, + "tool_name": "browser_navigate", + "arguments": { + "url": "https://example.com", + }, + "connector_name": "Playwright", + "tool_title": "Navigate", + }) + ); +} + +#[test] +fn guardian_review_decision_maps_to_mcp_tool_decision() { + assert_eq!( + mcp_tool_approval_decision_from_guardian(ReviewDecision::Approved), + McpToolApprovalDecision::Accept + ); + assert_eq!( + mcp_tool_approval_decision_from_guardian(ReviewDecision::Denied), + McpToolApprovalDecision::Decline + ); + assert_eq!( + mcp_tool_approval_decision_from_guardian(ReviewDecision::Abort), + McpToolApprovalDecision::Decline + ); +} + +#[test] +fn approval_elicitation_meta_includes_connector_source_for_codex_apps() { + assert_eq!( + build_mcp_tool_approval_elicitation_meta( + CODEX_APPS_MCP_SERVER_NAME, + Some(&approval_metadata( + Some("calendar"), + Some("Calendar"), + Some("Manage events and schedules."), + Some("Run Action"), + Some("Runs the selected action."), + )), + Some(&serde_json::json!({ + "calendar_id": "primary", + })), + None, + prompt_options(false, false), + ), + Some(serde_json::json!({ + MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, + MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR, + MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar", + MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar", + MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.", + MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action", + MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.", + MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { + "calendar_id": "primary", + }, + })) + ); +} + +#[test] +fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_source() { + assert_eq!( + build_mcp_tool_approval_elicitation_meta( + CODEX_APPS_MCP_SERVER_NAME, + Some(&approval_metadata( + Some("calendar"), + Some("Calendar"), + Some("Manage events and schedules."), + Some("Run Action"), + Some("Runs the selected action."), + )), + Some(&serde_json::json!({ + "calendar_id": "primary", + })), + None, + prompt_options(true, true), + ), + Some(serde_json::json!({ + MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, + MCP_TOOL_APPROVAL_PERSIST_KEY: [ + MCP_TOOL_APPROVAL_PERSIST_SESSION, + MCP_TOOL_APPROVAL_PERSIST_ALWAYS, + ], + MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR, + MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar", + MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar", + MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.", + MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action", + MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.", + MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { + "calendar_id": "primary", + }, + })) + ); +} + +#[test] +fn declined_elicitation_response_stays_decline() { + let response = parse_mcp_tool_approval_elicitation_response( + Some(ElicitationResponse { + action: ElicitationAction::Decline, + content: Some(serde_json::json!({ + "approval": MCP_TOOL_APPROVAL_ACCEPT, + })), + meta: None, + }), + "approval", + ); + + assert_eq!(response, McpToolApprovalDecision::Decline); +} + +#[test] +fn accepted_elicitation_response_uses_always_persist_meta() { + let response = parse_mcp_tool_approval_elicitation_response( + Some(ElicitationResponse { + action: ElicitationAction::Accept, + content: None, + meta: Some(serde_json::json!({ + MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_ALWAYS, + })), + }), + "approval", + ); + + assert_eq!(response, McpToolApprovalDecision::AcceptAndRemember); +} + +#[test] +fn accepted_elicitation_response_uses_session_persist_meta() { + let response = parse_mcp_tool_approval_elicitation_response( + Some(ElicitationResponse { + action: ElicitationAction::Accept, + content: None, + meta: Some(serde_json::json!({ + MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION, + })), + }), + "approval", + ); + + assert_eq!(response, McpToolApprovalDecision::AcceptForSession); +} + +#[test] +fn accepted_elicitation_without_content_defaults_to_accept() { + let response = parse_mcp_tool_approval_elicitation_response( + Some(ElicitationResponse { + action: ElicitationAction::Accept, + content: None, + meta: None, + }), + "approval", + ); + + assert_eq!(response, McpToolApprovalDecision::Accept); +} + +#[tokio::test] +async fn persist_codex_app_tool_approval_writes_tool_override() { + let tmp = tempdir().expect("tempdir"); + + persist_codex_app_tool_approval(tmp.path(), "calendar", "calendar/list_events") + .await + .expect("persist approval"); + + let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + let parsed: ConfigToml = toml::from_str(&contents).expect("parse config"); + + assert_eq!( + parsed.apps, + Some(AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: Some(AppToolsConfig { + tools: HashMap::from([( + "calendar/list_events".to_string(), + AppToolConfig { + enabled: None, + approval_mode: Some(AppToolApproval::Approve), + }, + )]), + }), + }, + )]), + }) + ); + assert!(contents.contains("[apps.calendar.tools.\"calendar/list_events\"]")); +} + +#[tokio::test] +async fn maybe_persist_mcp_tool_approval_reloads_session_config() { + let (session, turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home).expect("create codex home"); + let key = McpToolApprovalKey { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + connector_id: Some("calendar".to_string()), + tool_name: "calendar/list_events".to_string(), + }; + + maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await; + + let config = session.get_config().await; + let apps_toml = config + .config_layer_stack + .effective_config() + .as_table() + .and_then(|table| table.get("apps")) + .cloned() + .expect("apps table"); + let apps = AppsConfigToml::deserialize(apps_toml).expect("deserialize apps config"); + let tool = apps + .apps + .get("calendar") + .and_then(|app| app.tools.as_ref()) + .and_then(|tools| tools.tools.get("calendar/list_events")) + .expect("calendar/list_events tool config exists"); + + assert_eq!( + tool, + &AppToolConfig { + enabled: None, + approval_mode: Some(AppToolApproval::Approve), + } + ); + assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true); +} + +#[tokio::test] +async fn approve_mode_skips_when_annotations_do_not_require_approval() { + let (session, turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let invocation = McpInvocation { + server: "custom_server".to_string(), + tool: "read_only_tool".to_string(), + arguments: None, + }; + let metadata = McpToolApprovalMetadata { + annotations: Some(annotations(Some(true), None, None)), + connector_id: None, + connector_name: None, + connector_description: None, + tool_title: Some("Read Only Tool".to_string()), + tool_description: None, + }; + + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-1", + &invocation, + Some(&metadata), + AppToolApproval::Approve, + ) + .await; + + assert_eq!(decision, None); +} + +#[tokio::test] +async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + 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", + "short_reason": "needs approval", + "rationale": "high-risk action", + "risk_score": 96, + "risk_level": "critical", + "evidence": [{ + "message": "dangerous_tool", + "why": "high-risk action", + }], + }))) + .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); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let invocation = McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "dangerous_tool".to_string(), + arguments: Some(serde_json::json!({ "id": 1 })), + }; + let metadata = McpToolApprovalMetadata { + annotations: Some(annotations(Some(false), Some(true), Some(true))), + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: Some("Manage events".to_string()), + tool_title: Some("Dangerous Tool".to_string()), + tool_description: Some("Performs a risky action.".to_string()), + }; + + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-2", + &invocation, + Some(&metadata), + AppToolApproval::Approve, + ) + .await; + + assert_eq!( + decision, + Some(McpToolApprovalDecision::BlockedBySafetyMonitor( + "Tool call was cancelled because of safety risks: high-risk action".to_string(), + )) + ); +} diff --git a/codex-rs/core/src/memories/citations.rs b/codex-rs/core/src/memories/citations.rs index 91c7778266..ed620e853b 100644 --- a/codex-rs/core/src/memories/citations.rs +++ b/codex-rs/core/src/memories/citations.rs @@ -32,31 +32,5 @@ pub fn get_thread_id_from_citations(citations: Vec) -> Vec { } #[cfg(test)] -mod tests { - use super::get_thread_id_from_citations; - use codex_protocol::ThreadId; - use pretty_assertions::assert_eq; - - #[test] - fn get_thread_id_from_citations_extracts_thread_ids() { - let first = ThreadId::new(); - let second = ThreadId::new(); - - let citations = vec![format!( - "\n\nMEMORY.md:1-2|note=[x]\n\n\n{first}\nnot-a-uuid\n{second}\n\n" - )]; - - assert_eq!(get_thread_id_from_citations(citations), vec![first, second]); - } - - #[test] - fn get_thread_id_from_citations_supports_legacy_rollout_ids() { - let thread_id = ThreadId::new(); - - let citations = vec![format!( - "\n\n{thread_id}\n\n" - )]; - - assert_eq!(get_thread_id_from_citations(citations), vec![thread_id]); - } -} +#[path = "citations_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/memories/citations_tests.rs b/codex-rs/core/src/memories/citations_tests.rs new file mode 100644 index 0000000000..b6783dea7c --- /dev/null +++ b/codex-rs/core/src/memories/citations_tests.rs @@ -0,0 +1,26 @@ +use super::get_thread_id_from_citations; +use codex_protocol::ThreadId; +use pretty_assertions::assert_eq; + +#[test] +fn get_thread_id_from_citations_extracts_thread_ids() { + let first = ThreadId::new(); + let second = ThreadId::new(); + + let citations = vec![format!( + "\n\nMEMORY.md:1-2|note=[x]\n\n\n{first}\nnot-a-uuid\n{second}\n\n" + )]; + + assert_eq!(get_thread_id_from_citations(citations), vec![first, second]); +} + +#[test] +fn get_thread_id_from_citations_supports_legacy_rollout_ids() { + let thread_id = ThreadId::new(); + + let citations = vec![format!( + "\n\n{thread_id}\n\n" + )]; + + assert_eq!(get_thread_id_from_citations(citations), vec![thread_id]); +} diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index 96ee905e54..f6cd3039fd 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -579,72 +579,5 @@ fn emit_metrics(session: &Session, counts: &Stats) { } #[cfg(test)] -mod tests { - use super::JobOutcome; - use super::JobResult; - use super::aggregate_stats; - use codex_protocol::protocol::TokenUsage; - use pretty_assertions::assert_eq; - - #[test] - fn count_outcomes_sums_token_usage_across_all_jobs() { - let counts = aggregate_stats(vec![ - JobResult { - outcome: JobOutcome::SucceededWithOutput, - token_usage: Some(TokenUsage { - input_tokens: 10, - cached_input_tokens: 2, - output_tokens: 3, - reasoning_output_tokens: 1, - total_tokens: 13, - }), - }, - JobResult { - outcome: JobOutcome::SucceededNoOutput, - token_usage: Some(TokenUsage { - input_tokens: 7, - cached_input_tokens: 1, - output_tokens: 2, - reasoning_output_tokens: 0, - total_tokens: 9, - }), - }, - JobResult { - outcome: JobOutcome::Failed, - token_usage: None, - }, - ]); - - assert_eq!(counts.claimed, 3); - assert_eq!(counts.succeeded_with_output, 1); - assert_eq!(counts.succeeded_no_output, 1); - assert_eq!(counts.failed, 1); - assert_eq!( - counts.total_token_usage, - Some(TokenUsage { - input_tokens: 17, - cached_input_tokens: 3, - output_tokens: 5, - reasoning_output_tokens: 1, - total_tokens: 22, - }) - ); - } - - #[test] - fn count_outcomes_keeps_usage_empty_when_no_job_reports_it() { - let counts = aggregate_stats(vec![ - JobResult { - outcome: JobOutcome::SucceededWithOutput, - token_usage: None, - }, - JobResult { - outcome: JobOutcome::Failed, - token_usage: None, - }, - ]); - - assert_eq!(counts.claimed, 2); - assert_eq!(counts.total_token_usage, None); - } -} +#[path = "phase1_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/memories/phase1_tests.rs b/codex-rs/core/src/memories/phase1_tests.rs new file mode 100644 index 0000000000..c3e358187e --- /dev/null +++ b/codex-rs/core/src/memories/phase1_tests.rs @@ -0,0 +1,67 @@ +use super::JobOutcome; +use super::JobResult; +use super::aggregate_stats; +use codex_protocol::protocol::TokenUsage; +use pretty_assertions::assert_eq; + +#[test] +fn count_outcomes_sums_token_usage_across_all_jobs() { + let counts = aggregate_stats(vec![ + JobResult { + outcome: JobOutcome::SucceededWithOutput, + token_usage: Some(TokenUsage { + input_tokens: 10, + cached_input_tokens: 2, + output_tokens: 3, + reasoning_output_tokens: 1, + total_tokens: 13, + }), + }, + JobResult { + outcome: JobOutcome::SucceededNoOutput, + token_usage: Some(TokenUsage { + input_tokens: 7, + cached_input_tokens: 1, + output_tokens: 2, + reasoning_output_tokens: 0, + total_tokens: 9, + }), + }, + JobResult { + outcome: JobOutcome::Failed, + token_usage: None, + }, + ]); + + assert_eq!(counts.claimed, 3); + assert_eq!(counts.succeeded_with_output, 1); + assert_eq!(counts.succeeded_no_output, 1); + assert_eq!(counts.failed, 1); + assert_eq!( + counts.total_token_usage, + Some(TokenUsage { + input_tokens: 17, + cached_input_tokens: 3, + output_tokens: 5, + reasoning_output_tokens: 1, + total_tokens: 22, + }) + ); +} + +#[test] +fn count_outcomes_keeps_usage_empty_when_no_job_reports_it() { + let counts = aggregate_stats(vec![ + JobResult { + outcome: JobOutcome::SucceededWithOutput, + token_usage: None, + }, + JobResult { + outcome: JobOutcome::Failed, + token_usage: None, + }, + ]); + + assert_eq!(counts.claimed, 2); + assert_eq!(counts.total_token_usage, None); +} diff --git a/codex-rs/core/src/memories/prompts.rs b/codex-rs/core/src/memories/prompts.rs index 35cfe1edf0..1659e1c1c7 100644 --- a/codex-rs/core/src/memories/prompts.rs +++ b/codex-rs/core/src/memories/prompts.rs @@ -179,56 +179,5 @@ pub(crate) async fn build_memory_tool_developer_instructions(codex_home: &Path) } #[cfg(test)] -mod tests { - use super::*; - use crate::models_manager::model_info::model_info_from_slug; - - #[test] - fn build_stage_one_input_message_truncates_rollout_using_model_context_window() { - let input = format!("{}{}{}", "a".repeat(700_000), "middle", "z".repeat(700_000)); - let mut model_info = model_info_from_slug("gpt-5.2-codex"); - model_info.context_window = Some(123_000); - let expected_rollout_token_limit = usize::try_from( - ((123_000_i64 * model_info.effective_context_window_percent) / 100) - * phase_one::CONTEXT_WINDOW_PERCENT - / 100, - ) - .unwrap(); - let expected_truncated = truncate_text( - &input, - TruncationPolicy::Tokens(expected_rollout_token_limit), - ); - let message = build_stage_one_input_message( - &model_info, - Path::new("/tmp/rollout.jsonl"), - Path::new("/tmp"), - &input, - ) - .unwrap(); - - assert!(expected_truncated.contains("tokens truncated")); - assert!(expected_truncated.starts_with('a')); - assert!(expected_truncated.ends_with('z')); - assert!(message.contains(&expected_truncated)); - } - - #[test] - fn build_stage_one_input_message_uses_default_limit_when_model_context_window_missing() { - let input = format!("{}{}{}", "a".repeat(700_000), "middle", "z".repeat(700_000)); - let mut model_info = model_info_from_slug("gpt-5.2-codex"); - model_info.context_window = None; - let expected_truncated = truncate_text( - &input, - TruncationPolicy::Tokens(phase_one::DEFAULT_STAGE_ONE_ROLLOUT_TOKEN_LIMIT), - ); - let message = build_stage_one_input_message( - &model_info, - Path::new("/tmp/rollout.jsonl"), - Path::new("/tmp"), - &input, - ) - .unwrap(); - - assert!(message.contains(&expected_truncated)); - } -} +#[path = "prompts_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/memories/prompts_tests.rs b/codex-rs/core/src/memories/prompts_tests.rs new file mode 100644 index 0000000000..acbe5785a2 --- /dev/null +++ b/codex-rs/core/src/memories/prompts_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use crate::models_manager::model_info::model_info_from_slug; + +#[test] +fn build_stage_one_input_message_truncates_rollout_using_model_context_window() { + let input = format!("{}{}{}", "a".repeat(700_000), "middle", "z".repeat(700_000)); + let mut model_info = model_info_from_slug("gpt-5.2-codex"); + model_info.context_window = Some(123_000); + let expected_rollout_token_limit = usize::try_from( + ((123_000_i64 * model_info.effective_context_window_percent) / 100) + * phase_one::CONTEXT_WINDOW_PERCENT + / 100, + ) + .unwrap(); + let expected_truncated = truncate_text( + &input, + TruncationPolicy::Tokens(expected_rollout_token_limit), + ); + let message = build_stage_one_input_message( + &model_info, + Path::new("/tmp/rollout.jsonl"), + Path::new("/tmp"), + &input, + ) + .unwrap(); + + assert!(expected_truncated.contains("tokens truncated")); + assert!(expected_truncated.starts_with('a')); + assert!(expected_truncated.ends_with('z')); + assert!(message.contains(&expected_truncated)); +} + +#[test] +fn build_stage_one_input_message_uses_default_limit_when_model_context_window_missing() { + let input = format!("{}{}{}", "a".repeat(700_000), "middle", "z".repeat(700_000)); + let mut model_info = model_info_from_slug("gpt-5.2-codex"); + model_info.context_window = None; + let expected_truncated = truncate_text( + &input, + TruncationPolicy::Tokens(phase_one::DEFAULT_STAGE_ONE_ROLLOUT_TOKEN_LIMIT), + ); + let message = build_stage_one_input_message( + &model_info, + Path::new("/tmp/rollout.jsonl"), + Path::new("/tmp"), + &input, + ) + .unwrap(); + + assert!(message.contains(&expected_truncated)); +} diff --git a/codex-rs/core/src/memories/storage.rs b/codex-rs/core/src/memories/storage.rs index 68f75a095f..2455ae40df 100644 --- a/codex-rs/core/src/memories/storage.rs +++ b/codex-rs/core/src/memories/storage.rs @@ -256,75 +256,5 @@ pub(super) fn rollout_summary_file_stem_from_parts( } #[cfg(test)] -mod tests { - use super::rollout_summary_file_stem; - use super::rollout_summary_file_stem_from_parts; - use chrono::TimeZone; - use chrono::Utc; - use codex_protocol::ThreadId; - use codex_state::Stage1Output; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - const FIXED_PREFIX: &str = "2025-02-11T15-35-19-jqmb"; - - fn stage1_output_with_slug(thread_id: ThreadId, rollout_slug: Option<&str>) -> Stage1Output { - Stage1Output { - thread_id, - source_updated_at: Utc.timestamp_opt(123, 0).single().expect("timestamp"), - raw_memory: "raw memory".to_string(), - rollout_summary: "summary".to_string(), - rollout_slug: rollout_slug.map(ToString::to_string), - rollout_path: PathBuf::from("/tmp/rollout.jsonl"), - cwd: PathBuf::from("/tmp/workspace"), - git_branch: None, - generated_at: Utc.timestamp_opt(124, 0).single().expect("timestamp"), - } - } - - fn fixed_thread_id() -> ThreadId { - ThreadId::try_from("0194f5a6-89ab-7cde-8123-456789abcdef").expect("valid thread id") - } - - #[test] - fn rollout_summary_file_stem_uses_uuid_timestamp_and_hash_when_slug_missing() { - let thread_id = fixed_thread_id(); - let memory = stage1_output_with_slug(thread_id, None); - - assert_eq!(rollout_summary_file_stem(&memory), FIXED_PREFIX); - assert_eq!( - rollout_summary_file_stem_from_parts( - memory.thread_id, - memory.source_updated_at, - memory.rollout_slug.as_deref(), - ), - FIXED_PREFIX - ); - } - - #[test] - fn rollout_summary_file_stem_sanitizes_and_truncates_slug() { - let thread_id = fixed_thread_id(); - let memory = stage1_output_with_slug( - thread_id, - Some("Unsafe Slug/With Spaces & Symbols + EXTRA_LONG_12345_67890_ABCDE_fghij_klmno"), - ); - - let stem = rollout_summary_file_stem(&memory); - let slug = stem - .strip_prefix(&format!("{FIXED_PREFIX}-")) - .expect("slug suffix should be present"); - assert_eq!(slug.len(), 60); - assert_eq!( - slug, - "unsafe_slug_with_spaces___symbols___extra_long_12345_67890_a" - ); - } - - #[test] - fn rollout_summary_file_stem_uses_uuid_timestamp_and_hash_when_slug_is_empty() { - let thread_id = fixed_thread_id(); - let memory = stage1_output_with_slug(thread_id, Some("")); - - assert_eq!(rollout_summary_file_stem(&memory), FIXED_PREFIX); - } -} +#[path = "storage_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/memories/storage_tests.rs b/codex-rs/core/src/memories/storage_tests.rs new file mode 100644 index 0000000000..5e0f2ce89c --- /dev/null +++ b/codex-rs/core/src/memories/storage_tests.rs @@ -0,0 +1,70 @@ +use super::rollout_summary_file_stem; +use super::rollout_summary_file_stem_from_parts; +use chrono::TimeZone; +use chrono::Utc; +use codex_protocol::ThreadId; +use codex_state::Stage1Output; +use pretty_assertions::assert_eq; +use std::path::PathBuf; +const FIXED_PREFIX: &str = "2025-02-11T15-35-19-jqmb"; + +fn stage1_output_with_slug(thread_id: ThreadId, rollout_slug: Option<&str>) -> Stage1Output { + Stage1Output { + thread_id, + source_updated_at: Utc.timestamp_opt(123, 0).single().expect("timestamp"), + raw_memory: "raw memory".to_string(), + rollout_summary: "summary".to_string(), + rollout_slug: rollout_slug.map(ToString::to_string), + rollout_path: PathBuf::from("/tmp/rollout.jsonl"), + cwd: PathBuf::from("/tmp/workspace"), + git_branch: None, + generated_at: Utc.timestamp_opt(124, 0).single().expect("timestamp"), + } +} + +fn fixed_thread_id() -> ThreadId { + ThreadId::try_from("0194f5a6-89ab-7cde-8123-456789abcdef").expect("valid thread id") +} + +#[test] +fn rollout_summary_file_stem_uses_uuid_timestamp_and_hash_when_slug_missing() { + let thread_id = fixed_thread_id(); + let memory = stage1_output_with_slug(thread_id, None); + + assert_eq!(rollout_summary_file_stem(&memory), FIXED_PREFIX); + assert_eq!( + rollout_summary_file_stem_from_parts( + memory.thread_id, + memory.source_updated_at, + memory.rollout_slug.as_deref(), + ), + FIXED_PREFIX + ); +} + +#[test] +fn rollout_summary_file_stem_sanitizes_and_truncates_slug() { + let thread_id = fixed_thread_id(); + let memory = stage1_output_with_slug( + thread_id, + Some("Unsafe Slug/With Spaces & Symbols + EXTRA_LONG_12345_67890_ABCDE_fghij_klmno"), + ); + + let stem = rollout_summary_file_stem(&memory); + let slug = stem + .strip_prefix(&format!("{FIXED_PREFIX}-")) + .expect("slug suffix should be present"); + assert_eq!(slug.len(), 60); + assert_eq!( + slug, + "unsafe_slug_with_spaces___symbols___extra_long_12345_67890_a" + ); +} + +#[test] +fn rollout_summary_file_stem_uses_uuid_timestamp_and_hash_when_slug_is_empty() { + let thread_id = fixed_thread_id(); + let memory = stage1_output_with_slug(thread_id, Some("")); + + assert_eq!(rollout_summary_file_stem(&memory), FIXED_PREFIX); +} diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index 8914beaace..d32564aad2 100644 --- a/codex-rs/core/src/memories/tests.rs +++ b/codex-rs/core/src/memories/tests.rs @@ -547,10 +547,12 @@ mod phase2 { } async fn shutdown_threads(&self) { - self.manager - .remove_and_close_all_threads() - .await - .expect("shutdown spawned threads"); + let report = self + .manager + .shutdown_all_threads_bounded(std::time::Duration::from_secs(10)) + .await; + assert!(report.submit_failed.is_empty()); + assert!(report.timed_out.is_empty()); } fn user_input_ops_count(&self) -> usize { diff --git a/codex-rs/core/src/memory_trace.rs b/codex-rs/core/src/memory_trace.rs index 5cc4994427..2e613e6713 100644 --- a/codex-rs/core/src/memory_trace.rs +++ b/codex-rs/core/src/memory_trace.rs @@ -226,78 +226,5 @@ fn build_memory_id(index: usize, path: &Path) -> String { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::tempdir; - - #[test] - fn normalize_trace_items_handles_payload_wrapper_and_message_role_filtering() { - let items = vec![ - serde_json::json!({ - "type": "response_item", - "payload": {"type": "message", "role": "assistant", "content": []} - }), - serde_json::json!({ - "type": "response_item", - "payload": [ - {"type": "message", "role": "user", "content": []}, - {"type": "message", "role": "tool", "content": []}, - {"type": "function_call", "name": "shell", "arguments": "{}", "call_id": "c1"} - ] - }), - serde_json::json!({ - "type": "not_response_item", - "payload": {"type": "message", "role": "assistant", "content": []} - }), - serde_json::json!({ - "type": "message", - "role": "developer", - "content": [] - }), - ]; - - let normalized = normalize_trace_items(items, Path::new("trace.json")).expect("normalize"); - let expected = vec![ - serde_json::json!({"type": "message", "role": "assistant", "content": []}), - serde_json::json!({"type": "message", "role": "user", "content": []}), - serde_json::json!({"type": "function_call", "name": "shell", "arguments": "{}", "call_id": "c1"}), - serde_json::json!({"type": "message", "role": "developer", "content": []}), - ]; - assert_eq!(normalized, expected); - } - - #[test] - fn load_trace_items_supports_jsonl_arrays_and_objects() { - let text = r#" -{"type":"response_item","payload":{"type":"message","role":"assistant","content":[]}} -[{"type":"message","role":"user","content":[]},{"type":"message","role":"tool","content":[]}] -"#; - let loaded = load_trace_items(Path::new("trace.jsonl"), text).expect("load"); - let expected = vec![ - serde_json::json!({"type":"message","role":"assistant","content":[]}), - serde_json::json!({"type":"message","role":"user","content":[]}), - ]; - assert_eq!(loaded, expected); - } - - #[tokio::test] - async fn load_trace_text_decodes_utf8_sig() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("trace.json"); - tokio::fs::write( - &path, - [ - 0xEF, 0xBB, 0xBF, b'[', b'{', b'"', b't', b'y', b'p', b'e', b'"', b':', b'"', b'm', - b'e', b's', b's', b'a', b'g', b'e', b'"', b',', b'"', b'r', b'o', b'l', b'e', b'"', - b':', b'"', b'u', b's', b'e', b'r', b'"', b',', b'"', b'c', b'o', b'n', b't', b'e', - b'n', b't', b'"', b':', b'[', b']', b'}', b']', - ], - ) - .await - .expect("write"); - - let text = load_trace_text(&path).await.expect("decode"); - assert!(text.starts_with('[')); - } -} +#[path = "memory_trace_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/memory_trace_tests.rs b/codex-rs/core/src/memory_trace_tests.rs new file mode 100644 index 0000000000..e4014ef7a4 --- /dev/null +++ b/codex-rs/core/src/memory_trace_tests.rs @@ -0,0 +1,73 @@ +use super::*; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +#[test] +fn normalize_trace_items_handles_payload_wrapper_and_message_role_filtering() { + let items = vec![ + serde_json::json!({ + "type": "response_item", + "payload": {"type": "message", "role": "assistant", "content": []} + }), + serde_json::json!({ + "type": "response_item", + "payload": [ + {"type": "message", "role": "user", "content": []}, + {"type": "message", "role": "tool", "content": []}, + {"type": "function_call", "name": "shell", "arguments": "{}", "call_id": "c1"} + ] + }), + serde_json::json!({ + "type": "not_response_item", + "payload": {"type": "message", "role": "assistant", "content": []} + }), + serde_json::json!({ + "type": "message", + "role": "developer", + "content": [] + }), + ]; + + let normalized = normalize_trace_items(items, Path::new("trace.json")).expect("normalize"); + let expected = vec![ + serde_json::json!({"type": "message", "role": "assistant", "content": []}), + serde_json::json!({"type": "message", "role": "user", "content": []}), + serde_json::json!({"type": "function_call", "name": "shell", "arguments": "{}", "call_id": "c1"}), + serde_json::json!({"type": "message", "role": "developer", "content": []}), + ]; + assert_eq!(normalized, expected); +} + +#[test] +fn load_trace_items_supports_jsonl_arrays_and_objects() { + let text = r#" +{"type":"response_item","payload":{"type":"message","role":"assistant","content":[]}} +[{"type":"message","role":"user","content":[]},{"type":"message","role":"tool","content":[]}] +"#; + let loaded = load_trace_items(Path::new("trace.jsonl"), text).expect("load"); + let expected = vec![ + serde_json::json!({"type":"message","role":"assistant","content":[]}), + serde_json::json!({"type":"message","role":"user","content":[]}), + ]; + assert_eq!(loaded, expected); +} + +#[tokio::test] +async fn load_trace_text_decodes_utf8_sig() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("trace.json"); + tokio::fs::write( + &path, + [ + 0xEF, 0xBB, 0xBF, b'[', b'{', b'"', b't', b'y', b'p', b'e', b'"', b':', b'"', b'm', + b'e', b's', b's', b'a', b'g', b'e', b'"', b',', b'"', b'r', b'o', b'l', b'e', b'"', + b':', b'"', b'u', b's', b'e', b'r', b'"', b',', b'"', b'c', b'o', b'n', b't', b'e', + b'n', b't', b'"', b':', b'[', b']', b'}', b']', + ], + ) + .await + .expect("write"); + + let text = load_trace_text(&path).await.expect("decode"); + assert!(text.starts_with('[')); +} diff --git a/codex-rs/core/src/mentions.rs b/codex-rs/core/src/mentions.rs index ceaced7faa..fec7c40e4b 100644 --- a/codex-rs/core/src/mentions.rs +++ b/codex-rs/core/src/mentions.rs @@ -132,160 +132,5 @@ pub(crate) fn build_connector_slug_counts( } #[cfg(test)] -mod tests { - use std::collections::HashSet; - - use codex_protocol::user_input::UserInput; - use pretty_assertions::assert_eq; - - use super::collect_explicit_app_ids; - use super::collect_explicit_plugin_mentions; - use crate::plugins::PluginCapabilitySummary; - - fn text_input(text: &str) -> UserInput { - UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - } - } - - fn plugin(config_name: &str, display_name: &str) -> PluginCapabilitySummary { - PluginCapabilitySummary { - config_name: config_name.to_string(), - display_name: display_name.to_string(), - description: None, - has_skills: true, - mcp_server_names: Vec::new(), - app_connector_ids: Vec::new(), - } - } - - #[test] - fn collect_explicit_app_ids_from_linked_text_mentions() { - let input = vec![text_input("use [$calendar](app://calendar)")]; - - let app_ids = collect_explicit_app_ids(&input); - - assert_eq!(app_ids, HashSet::from(["calendar".to_string()])); - } - - #[test] - fn collect_explicit_app_ids_dedupes_structured_and_linked_mentions() { - let input = vec![ - text_input("use [$calendar](app://calendar)"), - UserInput::Mention { - name: "calendar".to_string(), - path: "app://calendar".to_string(), - }, - ]; - - let app_ids = collect_explicit_app_ids(&input); - - assert_eq!(app_ids, HashSet::from(["calendar".to_string()])); - } - - #[test] - fn collect_explicit_app_ids_ignores_non_app_paths() { - let input = vec![ - text_input( - "use [$docs](mcp://docs) and [$skill](skill://team/skill) and [$file](/tmp/file.txt)", - ), - UserInput::Mention { - name: "docs".to_string(), - path: "mcp://docs".to_string(), - }, - UserInput::Mention { - name: "skill".to_string(), - path: "skill://team/skill".to_string(), - }, - UserInput::Mention { - name: "file".to_string(), - path: "/tmp/file.txt".to_string(), - }, - ]; - - let app_ids = collect_explicit_app_ids(&input); - - assert_eq!(app_ids, HashSet::::new()); - } - - #[test] - fn collect_explicit_plugin_mentions_from_structured_paths() { - let plugins = vec![ - plugin("sample@test", "sample"), - plugin("other@test", "other"), - ]; - - let mentioned = collect_explicit_plugin_mentions( - &[UserInput::Mention { - name: "sample".to_string(), - path: "plugin://sample@test".to_string(), - }], - &plugins, - ); - - assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); - } - - #[test] - fn collect_explicit_plugin_mentions_from_linked_text_mentions() { - let plugins = vec![ - plugin("sample@test", "sample"), - plugin("other@test", "other"), - ]; - - let mentioned = collect_explicit_plugin_mentions( - &[text_input("use [@sample](plugin://sample@test)")], - &plugins, - ); - - assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); - } - - #[test] - fn collect_explicit_plugin_mentions_dedupes_structured_and_linked_mentions() { - let plugins = vec![ - plugin("sample@test", "sample"), - plugin("other@test", "other"), - ]; - - let mentioned = collect_explicit_plugin_mentions( - &[ - text_input("use [@sample](plugin://sample@test)"), - UserInput::Mention { - name: "sample".to_string(), - path: "plugin://sample@test".to_string(), - }, - ], - &plugins, - ); - - assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); - } - - #[test] - fn collect_explicit_plugin_mentions_ignores_non_plugin_paths() { - let plugins = vec![plugin("sample@test", "sample")]; - - let mentioned = collect_explicit_plugin_mentions( - &[text_input( - "use [$app](app://calendar) and [$skill](skill://team/skill) and [$file](/tmp/file.txt)", - )], - &plugins, - ); - - assert_eq!(mentioned, Vec::::new()); - } - - #[test] - fn collect_explicit_plugin_mentions_ignores_dollar_linked_plugin_mentions() { - let plugins = vec![plugin("sample@test", "sample")]; - - let mentioned = collect_explicit_plugin_mentions( - &[text_input("use [$sample](plugin://sample@test)")], - &plugins, - ); - - assert_eq!(mentioned, Vec::::new()); - } -} +#[path = "mentions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/mentions_tests.rs b/codex-rs/core/src/mentions_tests.rs new file mode 100644 index 0000000000..37c9adb886 --- /dev/null +++ b/codex-rs/core/src/mentions_tests.rs @@ -0,0 +1,155 @@ +use std::collections::HashSet; + +use codex_protocol::user_input::UserInput; +use pretty_assertions::assert_eq; + +use super::collect_explicit_app_ids; +use super::collect_explicit_plugin_mentions; +use crate::plugins::PluginCapabilitySummary; + +fn text_input(text: &str) -> UserInput { + UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + } +} + +fn plugin(config_name: &str, display_name: &str) -> PluginCapabilitySummary { + PluginCapabilitySummary { + config_name: config_name.to_string(), + display_name: display_name.to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + } +} + +#[test] +fn collect_explicit_app_ids_from_linked_text_mentions() { + let input = vec![text_input("use [$calendar](app://calendar)")]; + + let app_ids = collect_explicit_app_ids(&input); + + assert_eq!(app_ids, HashSet::from(["calendar".to_string()])); +} + +#[test] +fn collect_explicit_app_ids_dedupes_structured_and_linked_mentions() { + let input = vec![ + text_input("use [$calendar](app://calendar)"), + UserInput::Mention { + name: "calendar".to_string(), + path: "app://calendar".to_string(), + }, + ]; + + let app_ids = collect_explicit_app_ids(&input); + + assert_eq!(app_ids, HashSet::from(["calendar".to_string()])); +} + +#[test] +fn collect_explicit_app_ids_ignores_non_app_paths() { + let input = vec![ + text_input( + "use [$docs](mcp://docs) and [$skill](skill://team/skill) and [$file](/tmp/file.txt)", + ), + UserInput::Mention { + name: "docs".to_string(), + path: "mcp://docs".to_string(), + }, + UserInput::Mention { + name: "skill".to_string(), + path: "skill://team/skill".to_string(), + }, + UserInput::Mention { + name: "file".to_string(), + path: "/tmp/file.txt".to_string(), + }, + ]; + + let app_ids = collect_explicit_app_ids(&input); + + assert_eq!(app_ids, HashSet::::new()); +} + +#[test] +fn collect_explicit_plugin_mentions_from_structured_paths() { + let plugins = vec![ + plugin("sample@test", "sample"), + plugin("other@test", "other"), + ]; + + let mentioned = collect_explicit_plugin_mentions( + &[UserInput::Mention { + name: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + &plugins, + ); + + assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); +} + +#[test] +fn collect_explicit_plugin_mentions_from_linked_text_mentions() { + let plugins = vec![ + plugin("sample@test", "sample"), + plugin("other@test", "other"), + ]; + + let mentioned = collect_explicit_plugin_mentions( + &[text_input("use [@sample](plugin://sample@test)")], + &plugins, + ); + + assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); +} + +#[test] +fn collect_explicit_plugin_mentions_dedupes_structured_and_linked_mentions() { + let plugins = vec![ + plugin("sample@test", "sample"), + plugin("other@test", "other"), + ]; + + let mentioned = collect_explicit_plugin_mentions( + &[ + text_input("use [@sample](plugin://sample@test)"), + UserInput::Mention { + name: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, + ], + &plugins, + ); + + assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); +} + +#[test] +fn collect_explicit_plugin_mentions_ignores_non_plugin_paths() { + let plugins = vec![plugin("sample@test", "sample")]; + + let mentioned = collect_explicit_plugin_mentions( + &[text_input( + "use [$app](app://calendar) and [$skill](skill://team/skill) and [$file](/tmp/file.txt)", + )], + &plugins, + ); + + assert_eq!(mentioned, Vec::::new()); +} + +#[test] +fn collect_explicit_plugin_mentions_ignores_dollar_linked_plugin_mentions() { + let plugins = vec![plugin("sample@test", "sample")]; + + let mentioned = collect_explicit_plugin_mentions( + &[text_input("use [$sample](plugin://sample@test)")], + &plugins, + ); + + assert_eq!(mentioned, Vec::::new()); +} diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index cb3b10098c..9a2c534890 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -401,216 +401,5 @@ fn history_log_id(_metadata: &std::fs::Metadata) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use crate::config::ConfigBuilder; - use codex_protocol::ThreadId; - use pretty_assertions::assert_eq; - use std::fs::File; - use std::io::Write; - use tempfile::TempDir; - - #[tokio::test] - async fn lookup_reads_history_entries() { - let temp_dir = TempDir::new().expect("create temp dir"); - let history_path = temp_dir.path().join(HISTORY_FILENAME); - - let entries = vec![ - HistoryEntry { - session_id: "first-session".to_string(), - ts: 1, - text: "first".to_string(), - }, - HistoryEntry { - session_id: "second-session".to_string(), - ts: 2, - text: "second".to_string(), - }, - ]; - - let mut file = File::create(&history_path).expect("create history file"); - for entry in &entries { - writeln!( - file, - "{}", - serde_json::to_string(entry).expect("serialize history entry") - ) - .expect("write history entry"); - } - - let (log_id, count) = history_metadata_for_file(&history_path).await; - assert_eq!(count, entries.len()); - - let second_entry = - lookup_history_entry(&history_path, log_id, 1).expect("fetch second history entry"); - assert_eq!(second_entry, entries[1]); - } - - #[tokio::test] - async fn lookup_uses_stable_log_id_after_appends() { - let temp_dir = TempDir::new().expect("create temp dir"); - let history_path = temp_dir.path().join(HISTORY_FILENAME); - - let initial = HistoryEntry { - session_id: "first-session".to_string(), - ts: 1, - text: "first".to_string(), - }; - let appended = HistoryEntry { - session_id: "second-session".to_string(), - ts: 2, - text: "second".to_string(), - }; - - let mut file = File::create(&history_path).expect("create history file"); - writeln!( - file, - "{}", - serde_json::to_string(&initial).expect("serialize initial entry") - ) - .expect("write initial entry"); - - let (log_id, count) = history_metadata_for_file(&history_path).await; - assert_eq!(count, 1); - - let mut append = std::fs::OpenOptions::new() - .append(true) - .open(&history_path) - .expect("open history file for append"); - writeln!( - append, - "{}", - serde_json::to_string(&appended).expect("serialize appended entry") - ) - .expect("append history entry"); - - let fetched = - lookup_history_entry(&history_path, log_id, 1).expect("lookup appended history entry"); - assert_eq!(fetched, appended); - } - - #[tokio::test] - async fn append_entry_trims_history_when_beyond_max_bytes() { - let codex_home = TempDir::new().expect("create temp dir"); - - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load config"); - - let conversation_id = ThreadId::new(); - - let entry_one = "a".repeat(200); - let entry_two = "b".repeat(200); - - let history_path = codex_home.path().join("history.jsonl"); - - append_entry(&entry_one, &conversation_id, &config) - .await - .expect("write first entry"); - - let first_len = std::fs::metadata(&history_path).expect("metadata").len(); - let limit_bytes = first_len + 10; - - config.history.max_bytes = - Some(usize::try_from(limit_bytes).expect("limit should fit into usize")); - - append_entry(&entry_two, &conversation_id, &config) - .await - .expect("write second entry"); - - let contents = std::fs::read_to_string(&history_path).expect("read history"); - - let entries = contents - .lines() - .map(|line| serde_json::from_str::(line).expect("parse entry")) - .collect::>(); - - assert_eq!( - entries.len(), - 1, - "only one entry left because entry_one should be evicted" - ); - assert_eq!(entries[0].text, entry_two); - assert!(std::fs::metadata(&history_path).expect("metadata").len() <= limit_bytes); - } - - #[tokio::test] - async fn append_entry_trims_history_to_soft_cap() { - let codex_home = TempDir::new().expect("create temp dir"); - - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load config"); - - let conversation_id = ThreadId::new(); - - let short_entry = "a".repeat(200); - let long_entry = "b".repeat(400); - - let history_path = codex_home.path().join("history.jsonl"); - - append_entry(&short_entry, &conversation_id, &config) - .await - .expect("write first entry"); - - let short_entry_len = std::fs::metadata(&history_path).expect("metadata").len(); - - append_entry(&long_entry, &conversation_id, &config) - .await - .expect("write second entry"); - - let two_entry_len = std::fs::metadata(&history_path).expect("metadata").len(); - - let long_entry_len = two_entry_len - .checked_sub(short_entry_len) - .expect("second entry length should be larger than first entry length"); - - config.history.max_bytes = Some( - usize::try_from((2 * long_entry_len) + (short_entry_len / 2)) - .expect("max bytes should fit into usize"), - ); - - append_entry(&long_entry, &conversation_id, &config) - .await - .expect("write third entry"); - - let contents = std::fs::read_to_string(&history_path).expect("read history"); - - let entries = contents - .lines() - .map(|line| serde_json::from_str::(line).expect("parse entry")) - .collect::>(); - - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].text, long_entry); - - let pruned_len = std::fs::metadata(&history_path).expect("metadata").len(); - let max_bytes = config - .history - .max_bytes - .expect("max bytes should be configured") as u64; - - assert!(pruned_len <= max_bytes); - - let soft_cap_bytes = ((max_bytes as f64) * HISTORY_SOFT_CAP_RATIO) - .floor() - .clamp(1.0, max_bytes as f64) as u64; - let len_without_first = 2 * long_entry_len; - - assert!( - len_without_first <= max_bytes, - "dropping only the first entry would satisfy the hard cap" - ); - assert!( - len_without_first > soft_cap_bytes, - "soft cap should require more aggressive trimming than the hard cap" - ); - - assert_eq!(pruned_len, long_entry_len); - assert!(pruned_len <= soft_cap_bytes.max(long_entry_len)); - } -} +#[path = "message_history_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/message_history_tests.rs b/codex-rs/core/src/message_history_tests.rs new file mode 100644 index 0000000000..59b9c8c7b7 --- /dev/null +++ b/codex-rs/core/src/message_history_tests.rs @@ -0,0 +1,211 @@ +use super::*; +use crate::config::ConfigBuilder; +use codex_protocol::ThreadId; +use pretty_assertions::assert_eq; +use std::fs::File; +use std::io::Write; +use tempfile::TempDir; + +#[tokio::test] +async fn lookup_reads_history_entries() { + let temp_dir = TempDir::new().expect("create temp dir"); + let history_path = temp_dir.path().join(HISTORY_FILENAME); + + let entries = vec![ + HistoryEntry { + session_id: "first-session".to_string(), + ts: 1, + text: "first".to_string(), + }, + HistoryEntry { + session_id: "second-session".to_string(), + ts: 2, + text: "second".to_string(), + }, + ]; + + let mut file = File::create(&history_path).expect("create history file"); + for entry in &entries { + writeln!( + file, + "{}", + serde_json::to_string(entry).expect("serialize history entry") + ) + .expect("write history entry"); + } + + let (log_id, count) = history_metadata_for_file(&history_path).await; + assert_eq!(count, entries.len()); + + let second_entry = + lookup_history_entry(&history_path, log_id, 1).expect("fetch second history entry"); + assert_eq!(second_entry, entries[1]); +} + +#[tokio::test] +async fn lookup_uses_stable_log_id_after_appends() { + let temp_dir = TempDir::new().expect("create temp dir"); + let history_path = temp_dir.path().join(HISTORY_FILENAME); + + let initial = HistoryEntry { + session_id: "first-session".to_string(), + ts: 1, + text: "first".to_string(), + }; + let appended = HistoryEntry { + session_id: "second-session".to_string(), + ts: 2, + text: "second".to_string(), + }; + + let mut file = File::create(&history_path).expect("create history file"); + writeln!( + file, + "{}", + serde_json::to_string(&initial).expect("serialize initial entry") + ) + .expect("write initial entry"); + + let (log_id, count) = history_metadata_for_file(&history_path).await; + assert_eq!(count, 1); + + let mut append = std::fs::OpenOptions::new() + .append(true) + .open(&history_path) + .expect("open history file for append"); + writeln!( + append, + "{}", + serde_json::to_string(&appended).expect("serialize appended entry") + ) + .expect("append history entry"); + + let fetched = + lookup_history_entry(&history_path, log_id, 1).expect("lookup appended history entry"); + assert_eq!(fetched, appended); +} + +#[tokio::test] +async fn append_entry_trims_history_when_beyond_max_bytes() { + let codex_home = TempDir::new().expect("create temp dir"); + + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load config"); + + let conversation_id = ThreadId::new(); + + let entry_one = "a".repeat(200); + let entry_two = "b".repeat(200); + + let history_path = codex_home.path().join("history.jsonl"); + + append_entry(&entry_one, &conversation_id, &config) + .await + .expect("write first entry"); + + let first_len = std::fs::metadata(&history_path).expect("metadata").len(); + let limit_bytes = first_len + 10; + + config.history.max_bytes = + Some(usize::try_from(limit_bytes).expect("limit should fit into usize")); + + append_entry(&entry_two, &conversation_id, &config) + .await + .expect("write second entry"); + + let contents = std::fs::read_to_string(&history_path).expect("read history"); + + let entries = contents + .lines() + .map(|line| serde_json::from_str::(line).expect("parse entry")) + .collect::>(); + + assert_eq!( + entries.len(), + 1, + "only one entry left because entry_one should be evicted" + ); + assert_eq!(entries[0].text, entry_two); + assert!(std::fs::metadata(&history_path).expect("metadata").len() <= limit_bytes); +} + +#[tokio::test] +async fn append_entry_trims_history_to_soft_cap() { + let codex_home = TempDir::new().expect("create temp dir"); + + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load config"); + + let conversation_id = ThreadId::new(); + + let short_entry = "a".repeat(200); + let long_entry = "b".repeat(400); + + let history_path = codex_home.path().join("history.jsonl"); + + append_entry(&short_entry, &conversation_id, &config) + .await + .expect("write first entry"); + + let short_entry_len = std::fs::metadata(&history_path).expect("metadata").len(); + + append_entry(&long_entry, &conversation_id, &config) + .await + .expect("write second entry"); + + let two_entry_len = std::fs::metadata(&history_path).expect("metadata").len(); + + let long_entry_len = two_entry_len + .checked_sub(short_entry_len) + .expect("second entry length should be larger than first entry length"); + + config.history.max_bytes = Some( + usize::try_from((2 * long_entry_len) + (short_entry_len / 2)) + .expect("max bytes should fit into usize"), + ); + + append_entry(&long_entry, &conversation_id, &config) + .await + .expect("write third entry"); + + let contents = std::fs::read_to_string(&history_path).expect("read history"); + + let entries = contents + .lines() + .map(|line| serde_json::from_str::(line).expect("parse entry")) + .collect::>(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].text, long_entry); + + let pruned_len = std::fs::metadata(&history_path).expect("metadata").len(); + let max_bytes = config + .history + .max_bytes + .expect("max bytes should be configured") as u64; + + assert!(pruned_len <= max_bytes); + + let soft_cap_bytes = ((max_bytes as f64) * HISTORY_SOFT_CAP_RATIO) + .floor() + .clamp(1.0, max_bytes as f64) as u64; + let len_without_first = 2 * long_entry_len; + + assert!( + len_without_first <= max_bytes, + "dropping only the first entry would satisfy the hard cap" + ); + assert!( + len_without_first > soft_cap_bytes, + "soft cap should require more aggressive trimming than the hard cap" + ); + + assert_eq!(pruned_len, long_entry_len); + assert!(pruned_len <= soft_cap_bytes.max(long_entry_len)); +} diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 5d5ee36692..d8e2ea35ed 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -330,112 +330,5 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn test_deserialize_ollama_model_provider_toml() { - let azure_provider_toml = r#" -name = "Ollama" -base_url = "http://localhost:11434/v1" - "#; - let expected_provider = ModelProviderInfo { - name: "Ollama".into(), - base_url: Some("http://localhost:11434/v1".into()), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: None, - stream_max_retries: None, - stream_idle_timeout_ms: None, - requires_openai_auth: false, - supports_websockets: false, - }; - - let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); - assert_eq!(expected_provider, provider); - } - - #[test] - fn test_deserialize_azure_model_provider_toml() { - let azure_provider_toml = r#" -name = "Azure" -base_url = "https://xxxxx.openai.azure.com/openai" -env_key = "AZURE_OPENAI_API_KEY" -query_params = { api-version = "2025-04-01-preview" } - "#; - let expected_provider = ModelProviderInfo { - name: "Azure".into(), - base_url: Some("https://xxxxx.openai.azure.com/openai".into()), - env_key: Some("AZURE_OPENAI_API_KEY".into()), - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: WireApi::Responses, - query_params: Some(maplit::hashmap! { - "api-version".to_string() => "2025-04-01-preview".to_string(), - }), - http_headers: None, - env_http_headers: None, - request_max_retries: None, - stream_max_retries: None, - stream_idle_timeout_ms: None, - requires_openai_auth: false, - supports_websockets: false, - }; - - let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); - assert_eq!(expected_provider, provider); - } - - #[test] - fn test_deserialize_example_model_provider_toml() { - let azure_provider_toml = r#" -name = "Example" -base_url = "https://example.com" -env_key = "API_KEY" -http_headers = { "X-Example-Header" = "example-value" } -env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } - "#; - let expected_provider = ModelProviderInfo { - name: "Example".into(), - base_url: Some("https://example.com".into()), - env_key: Some("API_KEY".into()), - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: Some(maplit::hashmap! { - "X-Example-Header".to_string() => "example-value".to_string(), - }), - env_http_headers: Some(maplit::hashmap! { - "X-Example-Env-Header".to_string() => "EXAMPLE_ENV_VAR".to_string(), - }), - request_max_retries: None, - stream_max_retries: None, - stream_idle_timeout_ms: None, - requires_openai_auth: false, - supports_websockets: false, - }; - - let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); - assert_eq!(expected_provider, provider); - } - - #[test] - fn test_deserialize_chat_wire_api_shows_helpful_error() { - let provider_toml = r#" -name = "OpenAI using Chat Completions" -base_url = "https://api.openai.com/v1" -env_key = "OPENAI_API_KEY" -wire_api = "chat" - "#; - - let err = toml::from_str::(provider_toml).unwrap_err(); - assert!(err.to_string().contains(CHAT_WIRE_API_REMOVED_ERROR)); - } -} +#[path = "model_provider_info_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/model_provider_info_tests.rs b/codex-rs/core/src/model_provider_info_tests.rs new file mode 100644 index 0000000000..e6d5cea36b --- /dev/null +++ b/codex-rs/core/src/model_provider_info_tests.rs @@ -0,0 +1,107 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn test_deserialize_ollama_model_provider_toml() { + let azure_provider_toml = r#" +name = "Ollama" +base_url = "http://localhost:11434/v1" + "#; + let expected_provider = ModelProviderInfo { + name: "Ollama".into(), + base_url: Some("http://localhost:11434/v1".into()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: false, + }; + + let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); + assert_eq!(expected_provider, provider); +} + +#[test] +fn test_deserialize_azure_model_provider_toml() { + let azure_provider_toml = r#" +name = "Azure" +base_url = "https://xxxxx.openai.azure.com/openai" +env_key = "AZURE_OPENAI_API_KEY" +query_params = { api-version = "2025-04-01-preview" } + "#; + let expected_provider = ModelProviderInfo { + name: "Azure".into(), + base_url: Some("https://xxxxx.openai.azure.com/openai".into()), + env_key: Some("AZURE_OPENAI_API_KEY".into()), + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: Some(maplit::hashmap! { + "api-version".to_string() => "2025-04-01-preview".to_string(), + }), + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: false, + }; + + let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); + assert_eq!(expected_provider, provider); +} + +#[test] +fn test_deserialize_example_model_provider_toml() { + let azure_provider_toml = r#" +name = "Example" +base_url = "https://example.com" +env_key = "API_KEY" +http_headers = { "X-Example-Header" = "example-value" } +env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } + "#; + let expected_provider = ModelProviderInfo { + name: "Example".into(), + base_url: Some("https://example.com".into()), + env_key: Some("API_KEY".into()), + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: Some(maplit::hashmap! { + "X-Example-Header".to_string() => "example-value".to_string(), + }), + env_http_headers: Some(maplit::hashmap! { + "X-Example-Env-Header".to_string() => "EXAMPLE_ENV_VAR".to_string(), + }), + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: false, + }; + + let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); + assert_eq!(expected_provider, provider); +} + +#[test] +fn test_deserialize_chat_wire_api_shows_helpful_error() { + let provider_toml = r#" +name = "OpenAI using Chat Completions" +base_url = "https://api.openai.com/v1" +env_key = "OPENAI_API_KEY" +wire_api = "chat" + "#; + + let err = toml::from_str::(provider_toml).unwrap_err(); + assert!(err.to_string().contains(CHAT_WIRE_API_REMOVED_ERROR)); +} diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs index 5c5b212403..dceab9f3bd 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -103,57 +103,5 @@ fn asking_questions_guidance_message(default_mode_request_user_input: bool) -> S } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn preset_names_use_mode_display_names() { - assert_eq!(plan_preset().name, ModeKind::Plan.display_name()); - assert_eq!( - default_preset(CollaborationModesConfig::default()).name, - ModeKind::Default.display_name() - ); - assert_eq!( - plan_preset().reasoning_effort, - Some(Some(ReasoningEffort::Medium)) - ); - } - - #[test] - fn default_mode_instructions_replace_mode_names_placeholder() { - let default_instructions = default_preset(CollaborationModesConfig { - default_mode_request_user_input: true, - }) - .developer_instructions - .expect("default preset should include instructions") - .expect("default instructions should be set"); - - assert!(!default_instructions.contains(KNOWN_MODE_NAMES_PLACEHOLDER)); - assert!(!default_instructions.contains(REQUEST_USER_INPUT_AVAILABILITY_PLACEHOLDER)); - assert!(!default_instructions.contains(ASKING_QUESTIONS_GUIDANCE_PLACEHOLDER)); - - let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES); - let expected_snippet = format!("Known mode names are {known_mode_names}."); - assert!(default_instructions.contains(&expected_snippet)); - - let expected_availability_message = - request_user_input_availability_message(ModeKind::Default, true); - assert!(default_instructions.contains(&expected_availability_message)); - assert!(default_instructions.contains("prefer using the `request_user_input` tool")); - } - - #[test] - fn default_mode_instructions_use_plain_text_questions_when_feature_disabled() { - let default_instructions = default_preset(CollaborationModesConfig::default()) - .developer_instructions - .expect("default preset should include instructions") - .expect("default instructions should be set"); - - assert!(!default_instructions.contains("prefer using the `request_user_input` tool")); - assert!( - default_instructions - .contains("ask the user directly with a concise plain-text question") - ); - } -} +#[path = "collaboration_mode_presets_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets_tests.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets_tests.rs new file mode 100644 index 0000000000..b0969f6eba --- /dev/null +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn preset_names_use_mode_display_names() { + assert_eq!(plan_preset().name, ModeKind::Plan.display_name()); + assert_eq!( + default_preset(CollaborationModesConfig::default()).name, + ModeKind::Default.display_name() + ); + assert_eq!( + plan_preset().reasoning_effort, + Some(Some(ReasoningEffort::Medium)) + ); +} + +#[test] +fn default_mode_instructions_replace_mode_names_placeholder() { + let default_instructions = default_preset(CollaborationModesConfig { + default_mode_request_user_input: true, + }) + .developer_instructions + .expect("default preset should include instructions") + .expect("default instructions should be set"); + + assert!(!default_instructions.contains(KNOWN_MODE_NAMES_PLACEHOLDER)); + assert!(!default_instructions.contains(REQUEST_USER_INPUT_AVAILABILITY_PLACEHOLDER)); + assert!(!default_instructions.contains(ASKING_QUESTIONS_GUIDANCE_PLACEHOLDER)); + + let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES); + let expected_snippet = format!("Known mode names are {known_mode_names}."); + assert!(default_instructions.contains(&expected_snippet)); + + let expected_availability_message = + request_user_input_availability_message(ModeKind::Default, true); + assert!(default_instructions.contains(&expected_availability_message)); + assert!(default_instructions.contains("prefer using the `request_user_input` tool")); +} + +#[test] +fn default_mode_instructions_use_plain_text_questions_when_feature_disabled() { + let default_instructions = default_preset(CollaborationModesConfig::default()) + .developer_instructions + .expect("default preset should include instructions") + .expect("default instructions should be set"); + + assert!(!default_instructions.contains("prefer using the `request_user_input` tool")); + assert!( + default_instructions.contains("ask the user directly with a concise plain-text question") + ); +} diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 35e723d253..89c6cdcb35 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -428,584 +428,5 @@ impl ModelsManager { } #[cfg(test)] -mod tests { - use super::*; - use crate::CodexAuth; - use crate::auth::AuthCredentialsStoreMode; - use crate::config::ConfigBuilder; - use crate::model_provider_info::WireApi; - use chrono::Utc; - use codex_protocol::openai_models::ModelsResponse; - use core_test_support::responses::mount_models_once; - use pretty_assertions::assert_eq; - use serde_json::json; - use tempfile::tempdir; - use wiremock::MockServer; - - fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { - remote_model_with_visibility(slug, display, priority, "list") - } - - fn remote_model_with_visibility( - slug: &str, - display: &str, - priority: i32, - visibility: &str, - ) -> ModelInfo { - serde_json::from_value(json!({ - "slug": slug, - "display_name": display, - "description": format!("{display} desc"), - "default_reasoning_level": "medium", - "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}], - "shell_type": "shell_command", - "visibility": visibility, - "minimal_client_version": [0, 1, 0], - "supported_in_api": true, - "priority": priority, - "upgrade": null, - "base_instructions": "base instructions", - "supports_reasoning_summaries": false, - "support_verbosity": false, - "default_verbosity": null, - "apply_patch_tool_type": null, - "truncation_policy": {"mode": "bytes", "limit": 10_000}, - "supports_parallel_tool_calls": false, - "supports_image_detail_original": false, - "context_window": 272_000, - "experimental_supported_tools": [], - })) - .expect("valid model") - } - - fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) { - for model in expected { - assert!( - actual.iter().any(|candidate| candidate.slug == model.slug), - "expected model {} in cached list", - model.slug - ); - } - } - - fn provider_for(base_url: String) -> ModelProviderInfo { - ModelProviderInfo { - name: "mock".into(), - base_url: Some(base_url), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(5_000), - requires_openai_auth: false, - supports_websockets: false, - } - } - - #[tokio::test] - async fn get_model_info_tracks_fallback_usage() { - let codex_home = tempdir().expect("temp dir"); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let manager = ModelsManager::new( - codex_home.path().to_path_buf(), - auth_manager, - None, - CollaborationModesConfig::default(), - ); - let known_slug = manager - .get_remote_models() - .await - .first() - .expect("bundled models should include at least one model") - .slug - .clone(); - - let known = manager.get_model_info(known_slug.as_str(), &config).await; - assert!(!known.used_fallback_model_metadata); - assert_eq!(known.slug, known_slug); - - let unknown = manager - .get_model_info("model-that-does-not-exist", &config) - .await; - assert!(unknown.used_fallback_model_metadata); - assert_eq!(unknown.slug, "model-that-does-not-exist"); - } - - #[tokio::test] - async fn get_model_info_uses_custom_catalog() { - let codex_home = tempdir().expect("temp dir"); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - let mut overlay = remote_model("gpt-overlay", "Overlay", 0); - overlay.supports_image_detail_original = true; - - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let manager = ModelsManager::new( - codex_home.path().to_path_buf(), - auth_manager, - Some(ModelsResponse { - models: vec![overlay], - }), - CollaborationModesConfig::default(), - ); - - let model_info = manager - .get_model_info("gpt-overlay-experiment", &config) - .await; - - assert_eq!(model_info.slug, "gpt-overlay-experiment"); - assert_eq!(model_info.display_name, "Overlay"); - assert_eq!(model_info.context_window, Some(272_000)); - assert!(model_info.supports_image_detail_original); - assert!(!model_info.supports_parallel_tool_calls); - assert!(!model_info.used_fallback_model_metadata); - } - - #[tokio::test] - async fn get_model_info_matches_namespaced_suffix() { - let codex_home = tempdir().expect("temp dir"); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - let mut remote = remote_model("gpt-image", "Image", 0); - remote.supports_image_detail_original = true; - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let manager = ModelsManager::new( - codex_home.path().to_path_buf(), - auth_manager, - Some(ModelsResponse { - models: vec![remote], - }), - CollaborationModesConfig::default(), - ); - let namespaced_model = "custom/gpt-image".to_string(); - - let model_info = manager.get_model_info(&namespaced_model, &config).await; - - assert_eq!(model_info.slug, namespaced_model); - assert!(model_info.supports_image_detail_original); - assert!(!model_info.used_fallback_model_metadata); - } - - #[tokio::test] - async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { - let codex_home = tempdir().expect("temp dir"); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let manager = ModelsManager::new( - codex_home.path().to_path_buf(), - auth_manager, - None, - CollaborationModesConfig::default(), - ); - let known_slug = manager - .get_remote_models() - .await - .first() - .expect("bundled models should include at least one model") - .slug - .clone(); - let namespaced_model = format!("ns1/ns2/{known_slug}"); - - let model_info = manager.get_model_info(&namespaced_model, &config).await; - - assert_eq!(model_info.slug, namespaced_model); - assert!(model_info.used_fallback_model_metadata); - } - - #[tokio::test] - async fn refresh_available_models_sorts_by_priority() { - let server = MockServer::start().await; - let remote_models = vec![ - remote_model("priority-low", "Low", 1), - remote_model("priority-high", "High", 0), - ]; - let models_mock = mount_models_once( - &server, - ModelsResponse { - models: remote_models.clone(), - }, - ) - .await; - - let codex_home = tempdir().expect("temp dir"); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let provider = provider_for(server.uri()); - let manager = ModelsManager::with_provider_for_tests( - codex_home.path().to_path_buf(), - auth_manager, - provider, - ); - - manager - .refresh_available_models(RefreshStrategy::OnlineIfUncached) - .await - .expect("refresh succeeds"); - let cached_remote = manager.get_remote_models().await; - assert_models_contain(&cached_remote, &remote_models); - - let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; - let high_idx = available - .iter() - .position(|model| model.model == "priority-high") - .expect("priority-high should be listed"); - let low_idx = available - .iter() - .position(|model| model.model == "priority-low") - .expect("priority-low should be listed"); - assert!( - high_idx < low_idx, - "higher priority should be listed before lower priority" - ); - assert_eq!( - models_mock.requests().len(), - 1, - "expected a single /models request" - ); - } - - #[tokio::test] - async fn refresh_available_models_uses_cache_when_fresh() { - let server = MockServer::start().await; - let remote_models = vec![remote_model("cached", "Cached", 5)]; - let models_mock = mount_models_once( - &server, - ModelsResponse { - models: remote_models.clone(), - }, - ) - .await; - - let codex_home = tempdir().expect("temp dir"); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let provider = provider_for(server.uri()); - let manager = ModelsManager::with_provider_for_tests( - codex_home.path().to_path_buf(), - auth_manager, - provider, - ); - - manager - .refresh_available_models(RefreshStrategy::OnlineIfUncached) - .await - .expect("first refresh succeeds"); - assert_models_contain(&manager.get_remote_models().await, &remote_models); - - // Second call should read from cache and avoid the network. - manager - .refresh_available_models(RefreshStrategy::OnlineIfUncached) - .await - .expect("cached refresh succeeds"); - assert_models_contain(&manager.get_remote_models().await, &remote_models); - assert_eq!( - models_mock.requests().len(), - 1, - "cache hit should avoid a second /models request" - ); - } - - #[tokio::test] - async fn refresh_available_models_refetches_when_cache_stale() { - let server = MockServer::start().await; - let initial_models = vec![remote_model("stale", "Stale", 1)]; - let initial_mock = mount_models_once( - &server, - ModelsResponse { - models: initial_models.clone(), - }, - ) - .await; - - let codex_home = tempdir().expect("temp dir"); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let provider = provider_for(server.uri()); - let manager = ModelsManager::with_provider_for_tests( - codex_home.path().to_path_buf(), - auth_manager, - provider, - ); - - manager - .refresh_available_models(RefreshStrategy::OnlineIfUncached) - .await - .expect("initial refresh succeeds"); - - // Rewrite cache with an old timestamp so it is treated as stale. - manager - .cache_manager - .manipulate_cache_for_test(|fetched_at| { - *fetched_at = Utc::now() - chrono::Duration::hours(1); - }) - .await - .expect("cache manipulation succeeds"); - - let updated_models = vec![remote_model("fresh", "Fresh", 9)]; - server.reset().await; - let refreshed_mock = mount_models_once( - &server, - ModelsResponse { - models: updated_models.clone(), - }, - ) - .await; - - manager - .refresh_available_models(RefreshStrategy::OnlineIfUncached) - .await - .expect("second refresh succeeds"); - assert_models_contain(&manager.get_remote_models().await, &updated_models); - assert_eq!( - initial_mock.requests().len(), - 1, - "initial refresh should only hit /models once" - ); - assert_eq!( - refreshed_mock.requests().len(), - 1, - "stale cache refresh should fetch /models once" - ); - } - - #[tokio::test] - async fn refresh_available_models_refetches_when_version_mismatch() { - let server = MockServer::start().await; - let initial_models = vec![remote_model("old", "Old", 1)]; - let initial_mock = mount_models_once( - &server, - ModelsResponse { - models: initial_models.clone(), - }, - ) - .await; - - let codex_home = tempdir().expect("temp dir"); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let provider = provider_for(server.uri()); - let manager = ModelsManager::with_provider_for_tests( - codex_home.path().to_path_buf(), - auth_manager, - provider, - ); - - manager - .refresh_available_models(RefreshStrategy::OnlineIfUncached) - .await - .expect("initial refresh succeeds"); - - manager - .cache_manager - .mutate_cache_for_test(|cache| { - let client_version = crate::models_manager::client_version_to_whole(); - cache.client_version = Some(format!("{client_version}-mismatch")); - }) - .await - .expect("cache mutation succeeds"); - - let updated_models = vec![remote_model("new", "New", 2)]; - server.reset().await; - let refreshed_mock = mount_models_once( - &server, - ModelsResponse { - models: updated_models.clone(), - }, - ) - .await; - - manager - .refresh_available_models(RefreshStrategy::OnlineIfUncached) - .await - .expect("second refresh succeeds"); - assert_models_contain(&manager.get_remote_models().await, &updated_models); - assert_eq!( - initial_mock.requests().len(), - 1, - "initial refresh should only hit /models once" - ); - assert_eq!( - refreshed_mock.requests().len(), - 1, - "version mismatch should fetch /models once" - ); - } - - #[tokio::test] - async fn refresh_available_models_drops_removed_remote_models() { - let server = MockServer::start().await; - let initial_models = vec![remote_model("remote-old", "Remote Old", 1)]; - let initial_mock = mount_models_once( - &server, - ModelsResponse { - models: initial_models, - }, - ) - .await; - - let codex_home = tempdir().expect("temp dir"); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let provider = provider_for(server.uri()); - let mut manager = ModelsManager::with_provider_for_tests( - codex_home.path().to_path_buf(), - auth_manager, - provider, - ); - manager.cache_manager.set_ttl(Duration::ZERO); - - manager - .refresh_available_models(RefreshStrategy::OnlineIfUncached) - .await - .expect("initial refresh succeeds"); - - server.reset().await; - let refreshed_models = vec![remote_model("remote-new", "Remote New", 1)]; - let refreshed_mock = mount_models_once( - &server, - ModelsResponse { - models: refreshed_models, - }, - ) - .await; - - manager - .refresh_available_models(RefreshStrategy::OnlineIfUncached) - .await - .expect("second refresh succeeds"); - - let available = manager - .try_list_models() - .expect("models should be available"); - assert!( - available.iter().any(|preset| preset.model == "remote-new"), - "new remote model should be listed" - ); - assert!( - !available.iter().any(|preset| preset.model == "remote-old"), - "removed remote model should not be listed" - ); - assert_eq!( - initial_mock.requests().len(), - 1, - "initial refresh should only hit /models once" - ); - assert_eq!( - refreshed_mock.requests().len(), - 1, - "second refresh should only hit /models once" - ); - } - - #[tokio::test] - async fn refresh_available_models_skips_network_without_chatgpt_auth() { - let server = MockServer::start().await; - let dynamic_slug = "dynamic-model-only-for-test-noauth"; - let models_mock = mount_models_once( - &server, - ModelsResponse { - models: vec![remote_model(dynamic_slug, "No Auth", 1)], - }, - ) - .await; - - let codex_home = tempdir().expect("temp dir"); - let auth_manager = Arc::new(AuthManager::new( - codex_home.path().to_path_buf(), - false, - AuthCredentialsStoreMode::File, - )); - let provider = provider_for(server.uri()); - let manager = ModelsManager::with_provider_for_tests( - codex_home.path().to_path_buf(), - auth_manager, - provider, - ); - - manager - .refresh_available_models(RefreshStrategy::Online) - .await - .expect("refresh should no-op without chatgpt auth"); - let cached_remote = manager.get_remote_models().await; - assert!( - !cached_remote - .iter() - .any(|candidate| candidate.slug == dynamic_slug), - "remote refresh should be skipped without chatgpt auth" - ); - assert_eq!( - models_mock.requests().len(), - 0, - "no auth should avoid /models requests" - ); - } - - #[test] - fn build_available_models_picks_default_after_hiding_hidden_models() { - let codex_home = tempdir().expect("temp dir"); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let provider = provider_for("http://example.test".to_string()); - let manager = ModelsManager::with_provider_for_tests( - codex_home.path().to_path_buf(), - auth_manager, - provider, - ); - - let hidden_model = remote_model_with_visibility("hidden", "Hidden", 0, "hide"); - let visible_model = remote_model_with_visibility("visible", "Visible", 1, "list"); - - let expected_hidden = ModelPreset::from(hidden_model.clone()); - let mut expected_visible = ModelPreset::from(visible_model.clone()); - expected_visible.is_default = true; - - let available = manager.build_available_models(vec![hidden_model, visible_model]); - - assert_eq!(available, vec![expected_hidden, expected_visible]); - } - - #[test] - fn bundled_models_json_roundtrips() { - let file_contents = include_str!("../../models.json"); - let response: ModelsResponse = - serde_json::from_str(file_contents).expect("bundled models.json should deserialize"); - - let serialized = - serde_json::to_string(&response).expect("bundled models.json should serialize"); - let roundtripped: ModelsResponse = - serde_json::from_str(&serialized).expect("serialized models.json should deserialize"); - - assert_eq!( - response, roundtripped, - "bundled models.json should round trip through serde" - ); - assert!( - !response.models.is_empty(), - "bundled models.json should contain at least one model" - ); - } -} +#[path = "manager_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/models_manager/manager_tests.rs b/codex-rs/core/src/models_manager/manager_tests.rs new file mode 100644 index 0000000000..6981d6d799 --- /dev/null +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -0,0 +1,574 @@ +use super::*; +use crate::CodexAuth; +use crate::auth::AuthCredentialsStoreMode; +use crate::config::ConfigBuilder; +use crate::model_provider_info::WireApi; +use chrono::Utc; +use codex_protocol::openai_models::ModelsResponse; +use core_test_support::responses::mount_models_once; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::tempdir; +use wiremock::MockServer; + +fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { + remote_model_with_visibility(slug, display, priority, "list") +} + +fn remote_model_with_visibility( + slug: &str, + display: &str, + priority: i32, + visibility: &str, +) -> ModelInfo { + serde_json::from_value(json!({ + "slug": slug, + "display_name": display, + "description": format!("{display} desc"), + "default_reasoning_level": "medium", + "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}], + "shell_type": "shell_command", + "visibility": visibility, + "minimal_client_version": [0, 1, 0], + "supported_in_api": true, + "priority": priority, + "upgrade": null, + "base_instructions": "base instructions", + "supports_reasoning_summaries": false, + "support_verbosity": false, + "default_verbosity": null, + "apply_patch_tool_type": null, + "truncation_policy": {"mode": "bytes", "limit": 10_000}, + "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, + "context_window": 272_000, + "experimental_supported_tools": [], + })) + .expect("valid model") +} + +fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) { + for model in expected { + assert!( + actual.iter().any(|candidate| candidate.slug == model.slug), + "expected model {} in cached list", + model.slug + ); + } +} + +fn provider_for(base_url: String) -> ModelProviderInfo { + ModelProviderInfo { + name: "mock".into(), + base_url: Some(base_url), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(5_000), + requires_openai_auth: false, + supports_websockets: false, + } +} + +#[tokio::test] +async fn get_model_info_tracks_fallback_usage() { + let codex_home = tempdir().expect("temp dir"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new( + codex_home.path().to_path_buf(), + auth_manager, + None, + CollaborationModesConfig::default(), + ); + let known_slug = manager + .get_remote_models() + .await + .first() + .expect("bundled models should include at least one model") + .slug + .clone(); + + let known = manager.get_model_info(known_slug.as_str(), &config).await; + assert!(!known.used_fallback_model_metadata); + assert_eq!(known.slug, known_slug); + + let unknown = manager + .get_model_info("model-that-does-not-exist", &config) + .await; + assert!(unknown.used_fallback_model_metadata); + assert_eq!(unknown.slug, "model-that-does-not-exist"); +} + +#[tokio::test] +async fn get_model_info_uses_custom_catalog() { + let codex_home = tempdir().expect("temp dir"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + let mut overlay = remote_model("gpt-overlay", "Overlay", 0); + overlay.supports_image_detail_original = true; + + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new( + codex_home.path().to_path_buf(), + auth_manager, + Some(ModelsResponse { + models: vec![overlay], + }), + CollaborationModesConfig::default(), + ); + + let model_info = manager + .get_model_info("gpt-overlay-experiment", &config) + .await; + + assert_eq!(model_info.slug, "gpt-overlay-experiment"); + assert_eq!(model_info.display_name, "Overlay"); + assert_eq!(model_info.context_window, Some(272_000)); + assert!(model_info.supports_image_detail_original); + assert!(!model_info.supports_parallel_tool_calls); + assert!(!model_info.used_fallback_model_metadata); +} + +#[tokio::test] +async fn get_model_info_matches_namespaced_suffix() { + let codex_home = tempdir().expect("temp dir"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + let mut remote = remote_model("gpt-image", "Image", 0); + remote.supports_image_detail_original = true; + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new( + codex_home.path().to_path_buf(), + auth_manager, + Some(ModelsResponse { + models: vec![remote], + }), + CollaborationModesConfig::default(), + ); + let namespaced_model = "custom/gpt-image".to_string(); + + let model_info = manager.get_model_info(&namespaced_model, &config).await; + + assert_eq!(model_info.slug, namespaced_model); + assert!(model_info.supports_image_detail_original); + assert!(!model_info.used_fallback_model_metadata); +} + +#[tokio::test] +async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { + let codex_home = tempdir().expect("temp dir"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new( + codex_home.path().to_path_buf(), + auth_manager, + None, + CollaborationModesConfig::default(), + ); + let known_slug = manager + .get_remote_models() + .await + .first() + .expect("bundled models should include at least one model") + .slug + .clone(); + let namespaced_model = format!("ns1/ns2/{known_slug}"); + + let model_info = manager.get_model_info(&namespaced_model, &config).await; + + assert_eq!(model_info.slug, namespaced_model); + assert!(model_info.used_fallback_model_metadata); +} + +#[tokio::test] +async fn refresh_available_models_sorts_by_priority() { + let server = MockServer::start().await; + let remote_models = vec![ + remote_model("priority-low", "Low", 1), + remote_model("priority-high", "High", 0), + ]; + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: remote_models.clone(), + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + + manager + .refresh_available_models(RefreshStrategy::OnlineIfUncached) + .await + .expect("refresh succeeds"); + let cached_remote = manager.get_remote_models().await; + assert_models_contain(&cached_remote, &remote_models); + + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; + let high_idx = available + .iter() + .position(|model| model.model == "priority-high") + .expect("priority-high should be listed"); + let low_idx = available + .iter() + .position(|model| model.model == "priority-low") + .expect("priority-low should be listed"); + assert!( + high_idx < low_idx, + "higher priority should be listed before lower priority" + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); +} + +#[tokio::test] +async fn refresh_available_models_uses_cache_when_fresh() { + let server = MockServer::start().await; + let remote_models = vec![remote_model("cached", "Cached", 5)]; + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: remote_models.clone(), + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + + manager + .refresh_available_models(RefreshStrategy::OnlineIfUncached) + .await + .expect("first refresh succeeds"); + assert_models_contain(&manager.get_remote_models().await, &remote_models); + + // Second call should read from cache and avoid the network. + manager + .refresh_available_models(RefreshStrategy::OnlineIfUncached) + .await + .expect("cached refresh succeeds"); + assert_models_contain(&manager.get_remote_models().await, &remote_models); + assert_eq!( + models_mock.requests().len(), + 1, + "cache hit should avoid a second /models request" + ); +} + +#[tokio::test] +async fn refresh_available_models_refetches_when_cache_stale() { + let server = MockServer::start().await; + let initial_models = vec![remote_model("stale", "Stale", 1)]; + let initial_mock = mount_models_once( + &server, + ModelsResponse { + models: initial_models.clone(), + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + + manager + .refresh_available_models(RefreshStrategy::OnlineIfUncached) + .await + .expect("initial refresh succeeds"); + + // Rewrite cache with an old timestamp so it is treated as stale. + manager + .cache_manager + .manipulate_cache_for_test(|fetched_at| { + *fetched_at = Utc::now() - chrono::Duration::hours(1); + }) + .await + .expect("cache manipulation succeeds"); + + let updated_models = vec![remote_model("fresh", "Fresh", 9)]; + server.reset().await; + let refreshed_mock = mount_models_once( + &server, + ModelsResponse { + models: updated_models.clone(), + }, + ) + .await; + + manager + .refresh_available_models(RefreshStrategy::OnlineIfUncached) + .await + .expect("second refresh succeeds"); + assert_models_contain(&manager.get_remote_models().await, &updated_models); + assert_eq!( + initial_mock.requests().len(), + 1, + "initial refresh should only hit /models once" + ); + assert_eq!( + refreshed_mock.requests().len(), + 1, + "stale cache refresh should fetch /models once" + ); +} + +#[tokio::test] +async fn refresh_available_models_refetches_when_version_mismatch() { + let server = MockServer::start().await; + let initial_models = vec![remote_model("old", "Old", 1)]; + let initial_mock = mount_models_once( + &server, + ModelsResponse { + models: initial_models.clone(), + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + + manager + .refresh_available_models(RefreshStrategy::OnlineIfUncached) + .await + .expect("initial refresh succeeds"); + + manager + .cache_manager + .mutate_cache_for_test(|cache| { + let client_version = crate::models_manager::client_version_to_whole(); + cache.client_version = Some(format!("{client_version}-mismatch")); + }) + .await + .expect("cache mutation succeeds"); + + let updated_models = vec![remote_model("new", "New", 2)]; + server.reset().await; + let refreshed_mock = mount_models_once( + &server, + ModelsResponse { + models: updated_models.clone(), + }, + ) + .await; + + manager + .refresh_available_models(RefreshStrategy::OnlineIfUncached) + .await + .expect("second refresh succeeds"); + assert_models_contain(&manager.get_remote_models().await, &updated_models); + assert_eq!( + initial_mock.requests().len(), + 1, + "initial refresh should only hit /models once" + ); + assert_eq!( + refreshed_mock.requests().len(), + 1, + "version mismatch should fetch /models once" + ); +} + +#[tokio::test] +async fn refresh_available_models_drops_removed_remote_models() { + let server = MockServer::start().await; + let initial_models = vec![remote_model("remote-old", "Remote Old", 1)]; + let initial_mock = mount_models_once( + &server, + ModelsResponse { + models: initial_models, + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let provider = provider_for(server.uri()); + let mut manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + manager.cache_manager.set_ttl(Duration::ZERO); + + manager + .refresh_available_models(RefreshStrategy::OnlineIfUncached) + .await + .expect("initial refresh succeeds"); + + server.reset().await; + let refreshed_models = vec![remote_model("remote-new", "Remote New", 1)]; + let refreshed_mock = mount_models_once( + &server, + ModelsResponse { + models: refreshed_models, + }, + ) + .await; + + manager + .refresh_available_models(RefreshStrategy::OnlineIfUncached) + .await + .expect("second refresh succeeds"); + + let available = manager + .try_list_models() + .expect("models should be available"); + assert!( + available.iter().any(|preset| preset.model == "remote-new"), + "new remote model should be listed" + ); + assert!( + !available.iter().any(|preset| preset.model == "remote-old"), + "removed remote model should not be listed" + ); + assert_eq!( + initial_mock.requests().len(), + 1, + "initial refresh should only hit /models once" + ); + assert_eq!( + refreshed_mock.requests().len(), + 1, + "second refresh should only hit /models once" + ); +} + +#[tokio::test] +async fn refresh_available_models_skips_network_without_chatgpt_auth() { + let server = MockServer::start().await; + let dynamic_slug = "dynamic-model-only-for-test-noauth"; + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model(dynamic_slug, "No Auth", 1)], + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = Arc::new(AuthManager::new( + codex_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + + manager + .refresh_available_models(RefreshStrategy::Online) + .await + .expect("refresh should no-op without chatgpt auth"); + let cached_remote = manager.get_remote_models().await; + assert!( + !cached_remote + .iter() + .any(|candidate| candidate.slug == dynamic_slug), + "remote refresh should be skipped without chatgpt auth" + ); + assert_eq!( + models_mock.requests().len(), + 0, + "no auth should avoid /models requests" + ); +} + +#[test] +fn build_available_models_picks_default_after_hiding_hidden_models() { + let codex_home = tempdir().expect("temp dir"); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let provider = provider_for("http://example.test".to_string()); + let manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + + let hidden_model = remote_model_with_visibility("hidden", "Hidden", 0, "hide"); + let visible_model = remote_model_with_visibility("visible", "Visible", 1, "list"); + + let expected_hidden = ModelPreset::from(hidden_model.clone()); + let mut expected_visible = ModelPreset::from(visible_model.clone()); + expected_visible.is_default = true; + + let available = manager.build_available_models(vec![hidden_model, visible_model]); + + assert_eq!(available, vec![expected_hidden, expected_visible]); +} + +#[test] +fn bundled_models_json_roundtrips() { + let file_contents = include_str!("../../models.json"); + let response: ModelsResponse = + serde_json::from_str(file_contents).expect("bundled models.json should deserialize"); + + let serialized = + serde_json::to_string(&response).expect("bundled models.json should serialize"); + let roundtripped: ModelsResponse = + serde_json::from_str(&serialized).expect("serialized models.json should deserialize"); + + assert_eq!( + response, roundtripped, + "bundled models.json should round trip through serde" + ); + assert!( + !response.models.is_empty(), + "bundled models.json should contain at least one model" + ); +} diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 3664a52660..d82cb92b21 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -110,44 +110,5 @@ fn local_personality_messages_for_slug(slug: &str) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use crate::config::test_config; - use pretty_assertions::assert_eq; - - #[test] - fn reasoning_summaries_override_true_enables_support() { - let model = model_info_from_slug("unknown-model"); - let mut config = test_config(); - config.model_supports_reasoning_summaries = Some(true); - - let updated = with_config_overrides(model.clone(), &config); - let mut expected = model; - expected.supports_reasoning_summaries = true; - - assert_eq!(updated, expected); - } - - #[test] - fn reasoning_summaries_override_false_does_not_disable_support() { - let mut model = model_info_from_slug("unknown-model"); - model.supports_reasoning_summaries = true; - let mut config = test_config(); - config.model_supports_reasoning_summaries = Some(false); - - let updated = with_config_overrides(model.clone(), &config); - - assert_eq!(updated, model); - } - - #[test] - fn reasoning_summaries_override_false_is_noop_when_model_is_false() { - let model = model_info_from_slug("unknown-model"); - let mut config = test_config(); - config.model_supports_reasoning_summaries = Some(false); - - let updated = with_config_overrides(model.clone(), &config); - - assert_eq!(updated, model); - } -} +#[path = "model_info_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/models_manager/model_info_tests.rs b/codex-rs/core/src/models_manager/model_info_tests.rs new file mode 100644 index 0000000000..27bac8d70b --- /dev/null +++ b/codex-rs/core/src/models_manager/model_info_tests.rs @@ -0,0 +1,39 @@ +use super::*; +use crate::config::test_config; +use pretty_assertions::assert_eq; + +#[test] +fn reasoning_summaries_override_true_enables_support() { + let model = model_info_from_slug("unknown-model"); + let mut config = test_config(); + config.model_supports_reasoning_summaries = Some(true); + + let updated = with_config_overrides(model.clone(), &config); + let mut expected = model; + expected.supports_reasoning_summaries = true; + + assert_eq!(updated, expected); +} + +#[test] +fn reasoning_summaries_override_false_does_not_disable_support() { + let mut model = model_info_from_slug("unknown-model"); + model.supports_reasoning_summaries = true; + let mut config = test_config(); + config.model_supports_reasoning_summaries = Some(false); + + let updated = with_config_overrides(model.clone(), &config); + + assert_eq!(updated, model); +} + +#[test] +fn reasoning_summaries_override_false_is_noop_when_model_is_false() { + let model = model_info_from_slug("unknown-model"); + let mut config = test_config(); + config.model_supports_reasoning_summaries = Some(false); + + let updated = with_config_overrides(model.clone(), &config); + + assert_eq!(updated, model); +} diff --git a/codex-rs/core/src/network_policy_decision.rs b/codex-rs/core/src/network_policy_decision.rs index e40ae854c4..484905cfd9 100644 --- a/codex-rs/core/src/network_policy_decision.rs +++ b/codex-rs/core/src/network_policy_decision.rs @@ -121,196 +121,5 @@ pub(crate) fn execpolicy_network_rule_amendment( } #[cfg(test)] -mod tests { - use super::*; - use codex_network_proxy::BlockedRequest; - use codex_protocol::approvals::NetworkPolicyAmendment; - use codex_protocol::approvals::NetworkPolicyRuleAction; - use pretty_assertions::assert_eq; - - #[test] - fn network_approval_context_requires_ask_from_decider() { - let payload = NetworkPolicyDecisionPayload { - decision: NetworkPolicyDecision::Deny, - source: NetworkDecisionSource::Decider, - protocol: Some(NetworkApprovalProtocol::Https), - host: Some("example.com".to_string()), - reason: Some("not_allowed".to_string()), - port: Some(443), - }; - - assert_eq!(network_approval_context_from_payload(&payload), None); - } - - #[test] - fn network_approval_context_maps_http_https_and_socks_protocols() { - let http_payload = NetworkPolicyDecisionPayload { - decision: NetworkPolicyDecision::Ask, - source: NetworkDecisionSource::Decider, - protocol: Some(NetworkApprovalProtocol::Http), - host: Some("example.com".to_string()), - reason: Some("not_allowed".to_string()), - port: Some(80), - }; - assert_eq!( - network_approval_context_from_payload(&http_payload), - Some(NetworkApprovalContext { - host: "example.com".to_string(), - protocol: NetworkApprovalProtocol::Http, - }) - ); - - let https_payload = NetworkPolicyDecisionPayload { - decision: NetworkPolicyDecision::Ask, - source: NetworkDecisionSource::Decider, - protocol: Some(NetworkApprovalProtocol::Https), - host: Some("example.com".to_string()), - reason: Some("not_allowed".to_string()), - port: Some(443), - }; - assert_eq!( - network_approval_context_from_payload(&https_payload), - Some(NetworkApprovalContext { - host: "example.com".to_string(), - protocol: NetworkApprovalProtocol::Https, - }) - ); - - let http_connect_payload = NetworkPolicyDecisionPayload { - decision: NetworkPolicyDecision::Ask, - source: NetworkDecisionSource::Decider, - protocol: Some(NetworkApprovalProtocol::Https), - host: Some("example.com".to_string()), - reason: Some("not_allowed".to_string()), - port: Some(443), - }; - assert_eq!( - network_approval_context_from_payload(&http_connect_payload), - Some(NetworkApprovalContext { - host: "example.com".to_string(), - protocol: NetworkApprovalProtocol::Https, - }) - ); - - let socks5_tcp_payload = NetworkPolicyDecisionPayload { - decision: NetworkPolicyDecision::Ask, - source: NetworkDecisionSource::Decider, - protocol: Some(NetworkApprovalProtocol::Socks5Tcp), - host: Some("example.com".to_string()), - reason: Some("not_allowed".to_string()), - port: Some(443), - }; - assert_eq!( - network_approval_context_from_payload(&socks5_tcp_payload), - Some(NetworkApprovalContext { - host: "example.com".to_string(), - protocol: NetworkApprovalProtocol::Socks5Tcp, - }) - ); - - let socks5_udp_payload = NetworkPolicyDecisionPayload { - decision: NetworkPolicyDecision::Ask, - source: NetworkDecisionSource::Decider, - protocol: Some(NetworkApprovalProtocol::Socks5Udp), - host: Some("example.com".to_string()), - reason: Some("not_allowed".to_string()), - port: Some(443), - }; - assert_eq!( - network_approval_context_from_payload(&socks5_udp_payload), - Some(NetworkApprovalContext { - host: "example.com".to_string(), - protocol: NetworkApprovalProtocol::Socks5Udp, - }) - ); - } - - #[test] - fn network_policy_decision_payload_deserializes_proxy_protocol_aliases() { - let payload: NetworkPolicyDecisionPayload = serde_json::from_str( - r#"{ - "decision":"ask", - "source":"decider", - "protocol":"https_connect", - "host":"example.com", - "reason":"not_allowed", - "port":443 - }"#, - ) - .expect("payload should deserialize"); - assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https)); - - let payload: NetworkPolicyDecisionPayload = serde_json::from_str( - r#"{ - "decision":"ask", - "source":"decider", - "protocol":"http-connect", - "host":"example.com", - "reason":"not_allowed", - "port":443 - }"#, - ) - .expect("payload should deserialize"); - assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https)); - } - - #[test] - fn execpolicy_network_rule_amendment_maps_protocol_action_and_justification() { - let amendment = NetworkPolicyAmendment { - action: NetworkPolicyRuleAction::Deny, - host: "example.com".to_string(), - }; - let context = NetworkApprovalContext { - host: "example.com".to_string(), - protocol: NetworkApprovalProtocol::Socks5Udp, - }; - - assert_eq!( - execpolicy_network_rule_amendment(&amendment, &context, "example.com"), - ExecPolicyNetworkRuleAmendment { - protocol: ExecPolicyNetworkRuleProtocol::Socks5Udp, - decision: ExecPolicyDecision::Forbidden, - justification: "Deny socks5_udp access to example.com".to_string(), - } - ); - } - - #[test] - fn denied_network_policy_message_requires_deny_decision() { - let blocked = BlockedRequest { - host: "example.com".to_string(), - reason: "not_allowed".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - decision: Some("ask".to_string()), - source: Some("decider".to_string()), - port: Some(80), - timestamp: 0, - }; - assert_eq!(denied_network_policy_message(&blocked), None); - } - - #[test] - fn denied_network_policy_message_for_denylist_block_is_explicit() { - let blocked = BlockedRequest { - host: "example.com".to_string(), - reason: "denied".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - decision: Some("deny".to_string()), - source: Some("baseline_policy".to_string()), - port: Some(80), - timestamp: 0, - }; - assert_eq!( - denied_network_policy_message(&blocked), - Some( - "Network access to \"example.com\" was blocked: domain is explicitly denied by policy and cannot be approved from this prompt.".to_string() - ) - ); - } -} +#[path = "network_policy_decision_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/network_policy_decision_tests.rs b/codex-rs/core/src/network_policy_decision_tests.rs new file mode 100644 index 0000000000..ebb17f724f --- /dev/null +++ b/codex-rs/core/src/network_policy_decision_tests.rs @@ -0,0 +1,191 @@ +use super::*; +use codex_network_proxy::BlockedRequest; +use codex_protocol::approvals::NetworkPolicyAmendment; +use codex_protocol::approvals::NetworkPolicyRuleAction; +use pretty_assertions::assert_eq; + +#[test] +fn network_approval_context_requires_ask_from_decider() { + let payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Deny, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Https), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + + assert_eq!(network_approval_context_from_payload(&payload), None); +} + +#[test] +fn network_approval_context_maps_http_https_and_socks_protocols() { + let http_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Http), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(80), + }; + assert_eq!( + network_approval_context_from_payload(&http_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Http, + }) + ); + + let https_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Https), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&https_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }) + ); + + let http_connect_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Https), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&http_connect_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }) + ); + + let socks5_tcp_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Socks5Tcp), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&socks5_tcp_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Socks5Tcp, + }) + ); + + let socks5_udp_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Socks5Udp), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&socks5_udp_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Socks5Udp, + }) + ); +} + +#[test] +fn network_policy_decision_payload_deserializes_proxy_protocol_aliases() { + let payload: NetworkPolicyDecisionPayload = serde_json::from_str( + r#"{ + "decision":"ask", + "source":"decider", + "protocol":"https_connect", + "host":"example.com", + "reason":"not_allowed", + "port":443 + }"#, + ) + .expect("payload should deserialize"); + assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https)); + + let payload: NetworkPolicyDecisionPayload = serde_json::from_str( + r#"{ + "decision":"ask", + "source":"decider", + "protocol":"http-connect", + "host":"example.com", + "reason":"not_allowed", + "port":443 + }"#, + ) + .expect("payload should deserialize"); + assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https)); +} + +#[test] +fn execpolicy_network_rule_amendment_maps_protocol_action_and_justification() { + let amendment = NetworkPolicyAmendment { + action: NetworkPolicyRuleAction::Deny, + host: "example.com".to_string(), + }; + let context = NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Socks5Udp, + }; + + assert_eq!( + execpolicy_network_rule_amendment(&amendment, &context, "example.com"), + ExecPolicyNetworkRuleAmendment { + protocol: ExecPolicyNetworkRuleProtocol::Socks5Udp, + decision: ExecPolicyDecision::Forbidden, + justification: "Deny socks5_udp access to example.com".to_string(), + } + ); +} + +#[test] +fn denied_network_policy_message_requires_deny_decision() { + let blocked = BlockedRequest { + host: "example.com".to_string(), + reason: "not_allowed".to_string(), + client: None, + method: Some("GET".to_string()), + mode: None, + protocol: "http".to_string(), + decision: Some("ask".to_string()), + source: Some("decider".to_string()), + port: Some(80), + timestamp: 0, + }; + assert_eq!(denied_network_policy_message(&blocked), None); +} + +#[test] +fn denied_network_policy_message_for_denylist_block_is_explicit() { + let blocked = BlockedRequest { + host: "example.com".to_string(), + reason: "denied".to_string(), + client: None, + method: Some("GET".to_string()), + mode: None, + protocol: "http".to_string(), + decision: Some("deny".to_string()), + source: Some("baseline_policy".to_string()), + port: Some(80), + timestamp: 0, + }; + assert_eq!( + denied_network_policy_message(&blocked), + Some( + "Network access to \"example.com\" was blocked: domain is explicitly denied by policy and cannot be approved from this prompt.".to_string() + ) + ); +} diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 1c8244f70b..0509988510 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -304,109 +304,5 @@ impl ConfigReloader for MtimeConfigReloader { } #[cfg(test)] -mod tests { - use super::*; - - use codex_execpolicy::Decision; - use codex_execpolicy::NetworkRuleProtocol; - use codex_execpolicy::Policy; - use pretty_assertions::assert_eq; - - #[test] - fn higher_precedence_profile_network_beats_lower_profile_network() { - let lower_network: toml::Value = toml::from_str( - r#" -default_permissions = "workspace" - -[permissions.workspace.network] -allowed_domains = ["lower.example.com"] -"#, - ) - .expect("lower layer should parse"); - let higher_network: toml::Value = toml::from_str( - r#" -default_permissions = "workspace" - -[permissions.workspace.network] -allowed_domains = ["higher.example.com"] -"#, - ) - .expect("higher layer should parse"); - - let mut config = NetworkProxyConfig::default(); - apply_network_tables( - &mut config, - network_tables_from_toml(&lower_network).expect("lower layer should deserialize"), - ) - .expect("lower layer should apply"); - apply_network_tables( - &mut config, - network_tables_from_toml(&higher_network).expect("higher layer should deserialize"), - ) - .expect("higher layer should apply"); - - assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]); - } - - #[test] - fn execpolicy_network_rules_overlay_network_lists() { - let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["config.example.com".to_string()]; - config.network.denied_domains = vec!["blocked.example.com".to_string()]; - - let mut exec_policy = Policy::empty(); - exec_policy - .add_network_rule( - "blocked.example.com", - NetworkRuleProtocol::Https, - Decision::Allow, - None, - ) - .expect("allow rule should be valid"); - exec_policy - .add_network_rule( - "api.example.com", - NetworkRuleProtocol::Http, - Decision::Forbidden, - None, - ) - .expect("deny rule should be valid"); - - apply_exec_policy_network_rules(&mut config, &exec_policy); - - assert_eq!( - config.network.allowed_domains, - vec![ - "config.example.com".to_string(), - "blocked.example.com".to_string() - ] - ); - assert_eq!( - config.network.denied_domains, - vec!["api.example.com".to_string()] - ); - } - - #[test] - fn apply_network_constraints_includes_allow_all_unix_sockets_flag() { - let config: toml::Value = toml::from_str( - r#" -default_permissions = "workspace" - -[permissions.workspace.network] -dangerously_allow_all_unix_sockets = true -"#, - ) - .expect("permissions profile should parse"); - let network = selected_network_from_tables( - network_tables_from_toml(&config).expect("permissions profile should deserialize"), - ) - .expect("permissions profile should select a network table") - .expect("network table should be present"); - - let mut constraints = NetworkProxyConstraints::default(); - apply_network_constraints(network, &mut constraints); - - assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true)); - } -} +#[path = "network_proxy_loader_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/network_proxy_loader_tests.rs b/codex-rs/core/src/network_proxy_loader_tests.rs new file mode 100644 index 0000000000..018061463b --- /dev/null +++ b/codex-rs/core/src/network_proxy_loader_tests.rs @@ -0,0 +1,104 @@ +use super::*; + +use codex_execpolicy::Decision; +use codex_execpolicy::NetworkRuleProtocol; +use codex_execpolicy::Policy; +use pretty_assertions::assert_eq; + +#[test] +fn higher_precedence_profile_network_beats_lower_profile_network() { + let lower_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] +allowed_domains = ["lower.example.com"] +"#, + ) + .expect("lower layer should parse"); + let higher_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] +allowed_domains = ["higher.example.com"] +"#, + ) + .expect("higher layer should parse"); + + let mut config = NetworkProxyConfig::default(); + apply_network_tables( + &mut config, + network_tables_from_toml(&lower_network).expect("lower layer should deserialize"), + ) + .expect("lower layer should apply"); + apply_network_tables( + &mut config, + network_tables_from_toml(&higher_network).expect("higher layer should deserialize"), + ) + .expect("higher layer should apply"); + + assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]); +} + +#[test] +fn execpolicy_network_rules_overlay_network_lists() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["config.example.com".to_string()]; + config.network.denied_domains = vec!["blocked.example.com".to_string()]; + + let mut exec_policy = Policy::empty(); + exec_policy + .add_network_rule( + "blocked.example.com", + NetworkRuleProtocol::Https, + Decision::Allow, + None, + ) + .expect("allow rule should be valid"); + exec_policy + .add_network_rule( + "api.example.com", + NetworkRuleProtocol::Http, + Decision::Forbidden, + None, + ) + .expect("deny rule should be valid"); + + apply_exec_policy_network_rules(&mut config, &exec_policy); + + assert_eq!( + config.network.allowed_domains, + vec![ + "config.example.com".to_string(), + "blocked.example.com".to_string() + ] + ); + assert_eq!( + config.network.denied_domains, + vec!["api.example.com".to_string()] + ); +} + +#[test] +fn apply_network_constraints_includes_allow_all_unix_sockets_flag() { + let config: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] +dangerously_allow_all_unix_sockets = true +"#, + ) + .expect("permissions profile should parse"); + let network = selected_network_from_tables( + network_tables_from_toml(&config).expect("permissions profile should deserialize"), + ) + .expect("permissions profile should select a network table") + .expect("network table should be present"); + + let mut constraints = NetworkProxyConstraints::default(); + apply_network_constraints(network, &mut constraints); + + assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true)); +} diff --git a/codex-rs/core/src/original_image_detail.rs b/codex-rs/core/src/original_image_detail.rs new file mode 100644 index 0000000000..d5bb6d24cd --- /dev/null +++ b/codex-rs/core/src/original_image_detail.rs @@ -0,0 +1,28 @@ +use crate::features::Feature; +use crate::features::Features; +use codex_protocol::models::ImageDetail; +use codex_protocol::openai_models::ModelInfo; + +pub(crate) fn can_request_original_image_detail( + features: &Features, + model_info: &ModelInfo, +) -> bool { + model_info.supports_image_detail_original && features.enabled(Feature::ImageDetailOriginal) +} + +pub(crate) fn normalize_output_image_detail( + features: &Features, + model_info: &ModelInfo, + detail: Option, +) -> Option { + match detail { + Some(ImageDetail::Original) if can_request_original_image_detail(features, model_info) => { + Some(ImageDetail::Original) + } + Some(ImageDetail::Original) | Some(_) | None => None, + } +} + +#[cfg(test)] +#[path = "original_image_detail_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/original_image_detail_tests.rs b/codex-rs/core/src/original_image_detail_tests.rs new file mode 100644 index 0000000000..b771e87bb4 --- /dev/null +++ b/codex-rs/core/src/original_image_detail_tests.rs @@ -0,0 +1,63 @@ +use super::*; + +use crate::config::test_config; +use crate::features::Features; +use crate::models_manager::manager::ModelsManager; +use pretty_assertions::assert_eq; + +#[test] +fn image_detail_original_feature_enables_explicit_original_without_force() { + let config = test_config(); + let mut model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + model_info.supports_image_detail_original = true; + let mut features = Features::with_defaults(); + features.enable(Feature::ImageDetailOriginal); + + assert!(can_request_original_image_detail(&features, &model_info)); + assert_eq!( + normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Original)), + Some(ImageDetail::Original) + ); + assert_eq!( + normalize_output_image_detail(&features, &model_info, None), + None + ); +} + +#[test] +fn explicit_original_is_dropped_without_feature_or_model_support() { + let config = test_config(); + let mut model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + model_info.supports_image_detail_original = true; + let features = Features::with_defaults(); + + assert_eq!( + normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Original)), + None + ); + + let mut features = Features::with_defaults(); + features.enable(Feature::ImageDetailOriginal); + model_info.supports_image_detail_original = false; + assert_eq!( + normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Original)), + None + ); +} + +#[test] +fn unsupported_non_original_detail_is_dropped() { + let config = test_config(); + let mut model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + model_info.supports_image_detail_original = true; + let mut features = Features::with_defaults(); + features.enable(Feature::ImageDetailOriginal); + + assert_eq!( + normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Low)), + None + ); +} diff --git a/codex-rs/core/src/path_utils.rs b/codex-rs/core/src/path_utils.rs index af7d620777..eca2ce1663 100644 --- a/codex-rs/core/src/path_utils.rs +++ b/codex-rs/core/src/path_utils.rs @@ -199,83 +199,5 @@ fn lower_ascii_path(path: PathBuf) -> PathBuf { } #[cfg(test)] -mod tests { - #[cfg(unix)] - mod symlinks { - use super::super::resolve_symlink_write_paths; - use pretty_assertions::assert_eq; - use std::os::unix::fs::symlink; - - #[test] - fn symlink_cycles_fall_back_to_root_write_path() -> std::io::Result<()> { - let dir = tempfile::tempdir()?; - let a = dir.path().join("a"); - let b = dir.path().join("b"); - - symlink(&b, &a)?; - symlink(&a, &b)?; - - let resolved = resolve_symlink_write_paths(&a)?; - - assert_eq!(resolved.read_path, None); - assert_eq!(resolved.write_path, a); - Ok(()) - } - } - - #[cfg(target_os = "linux")] - mod wsl { - use super::super::normalize_for_wsl_with_flag; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - - #[test] - fn wsl_mnt_drive_paths_lowercase() { - let normalized = normalize_for_wsl_with_flag(PathBuf::from("/mnt/C/Users/Dev"), true); - - assert_eq!(normalized, PathBuf::from("/mnt/c/users/dev")); - } - - #[test] - fn wsl_non_drive_paths_unchanged() { - let path = PathBuf::from("/mnt/cc/Users/Dev"); - let normalized = normalize_for_wsl_with_flag(path.clone(), true); - - assert_eq!(normalized, path); - } - - #[test] - fn wsl_non_mnt_paths_unchanged() { - let path = PathBuf::from("/home/Dev"); - let normalized = normalize_for_wsl_with_flag(path.clone(), true); - - assert_eq!(normalized, path); - } - } - - mod native_workdir { - use super::super::normalize_for_native_workdir_with_flag; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - - #[cfg(target_os = "windows")] - #[test] - fn windows_verbatim_paths_are_simplified() { - let path = PathBuf::from(r"\\?\D:\c\x\worktrees\2508\swift-base"); - let normalized = normalize_for_native_workdir_with_flag(path, true); - - assert_eq!( - normalized, - PathBuf::from(r"D:\c\x\worktrees\2508\swift-base") - ); - } - - #[test] - fn non_windows_paths_are_unchanged() { - let path = PathBuf::from(r"\\?\D:\c\x\worktrees\2508\swift-base"); - let normalized = normalize_for_native_workdir_with_flag(path.clone(), false); - - assert_eq!(normalized, path); - } - } -} +#[path = "path_utils_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/path_utils_tests.rs b/codex-rs/core/src/path_utils_tests.rs new file mode 100644 index 0000000000..f028133f12 --- /dev/null +++ b/codex-rs/core/src/path_utils_tests.rs @@ -0,0 +1,78 @@ +#[cfg(unix)] +mod symlinks { + use super::super::resolve_symlink_write_paths; + use pretty_assertions::assert_eq; + use std::os::unix::fs::symlink; + + #[test] + fn symlink_cycles_fall_back_to_root_write_path() -> std::io::Result<()> { + let dir = tempfile::tempdir()?; + let a = dir.path().join("a"); + let b = dir.path().join("b"); + + symlink(&b, &a)?; + symlink(&a, &b)?; + + let resolved = resolve_symlink_write_paths(&a)?; + + assert_eq!(resolved.read_path, None); + assert_eq!(resolved.write_path, a); + Ok(()) + } +} + +#[cfg(target_os = "linux")] +mod wsl { + use super::super::normalize_for_wsl_with_flag; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn wsl_mnt_drive_paths_lowercase() { + let normalized = normalize_for_wsl_with_flag(PathBuf::from("/mnt/C/Users/Dev"), true); + + assert_eq!(normalized, PathBuf::from("/mnt/c/users/dev")); + } + + #[test] + fn wsl_non_drive_paths_unchanged() { + let path = PathBuf::from("/mnt/cc/Users/Dev"); + let normalized = normalize_for_wsl_with_flag(path.clone(), true); + + assert_eq!(normalized, path); + } + + #[test] + fn wsl_non_mnt_paths_unchanged() { + let path = PathBuf::from("/home/Dev"); + let normalized = normalize_for_wsl_with_flag(path.clone(), true); + + assert_eq!(normalized, path); + } +} + +mod native_workdir { + use super::super::normalize_for_native_workdir_with_flag; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[cfg(target_os = "windows")] + #[test] + fn windows_verbatim_paths_are_simplified() { + let path = PathBuf::from(r"\\?\D:\c\x\worktrees\2508\swift-base"); + let normalized = normalize_for_native_workdir_with_flag(path, true); + + assert_eq!( + normalized, + PathBuf::from(r"D:\c\x\worktrees\2508\swift-base") + ); + } + + #[test] + fn non_windows_paths_are_unchanged() { + let path = PathBuf::from(r"\\?\D:\c\x\worktrees\2508\swift-base"); + let normalized = normalize_for_native_workdir_with_flag(path.clone(), false); + + assert_eq!(normalized, path); + } +} diff --git a/codex-rs/core/src/personality_migration.rs b/codex-rs/core/src/personality_migration.rs index 934ff89379..9e541c30a2 100644 --- a/codex-rs/core/src/personality_migration.rs +++ b/codex-rs/core/src/personality_migration.rs @@ -131,138 +131,5 @@ async fn create_marker(marker_path: &Path) -> io::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - use codex_protocol::ThreadId; - use codex_protocol::protocol::EventMsg; - use codex_protocol::protocol::RolloutItem; - use codex_protocol::protocol::RolloutLine; - use codex_protocol::protocol::SessionMeta; - use codex_protocol::protocol::SessionMetaLine; - use codex_protocol::protocol::SessionSource; - use codex_protocol::protocol::UserMessageEvent; - use pretty_assertions::assert_eq; - use tempfile::TempDir; - use tokio::io::AsyncWriteExt; - - const TEST_TIMESTAMP: &str = "2025-01-01T00-00-00"; - - async fn read_config_toml(codex_home: &Path) -> io::Result { - let contents = tokio::fs::read_to_string(codex_home.join("config.toml")).await?; - toml::from_str(&contents).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) - } - - async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> { - let thread_id = ThreadId::new(); - let dir = codex_home - .join(SESSIONS_SUBDIR) - .join("2025") - .join("01") - .join("01"); - tokio::fs::create_dir_all(&dir).await?; - let file_path = dir.join(format!("rollout-{TEST_TIMESTAMP}-{thread_id}.jsonl")); - let mut file = tokio::fs::File::create(&file_path).await?; - - let session_meta = SessionMetaLine { - meta: SessionMeta { - id: thread_id, - forked_from_id: None, - timestamp: TEST_TIMESTAMP.to_string(), - cwd: std::path::PathBuf::from("."), - originator: "test_originator".to_string(), - cli_version: "test_version".to_string(), - source: SessionSource::Cli, - agent_nickname: None, - agent_role: None, - model_provider: None, - base_instructions: None, - dynamic_tools: None, - memory_mode: None, - }, - git: None, - }; - let meta_line = RolloutLine { - timestamp: TEST_TIMESTAMP.to_string(), - item: RolloutItem::SessionMeta(session_meta), - }; - let user_event = RolloutLine { - timestamp: TEST_TIMESTAMP.to_string(), - item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { - message: "hello".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - })), - }; - - file.write_all(format!("{}\n", serde_json::to_string(&meta_line)?).as_bytes()) - .await?; - file.write_all(format!("{}\n", serde_json::to_string(&user_event)?).as_bytes()) - .await?; - Ok(()) - } - - #[tokio::test] - async fn applies_when_sessions_exist_and_no_personality() -> io::Result<()> { - let temp = TempDir::new()?; - write_session_with_user_event(temp.path()).await?; - - let config_toml = ConfigToml::default(); - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; - - assert_eq!(status, PersonalityMigrationStatus::Applied); - assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); - - let persisted = read_config_toml(temp.path()).await?; - assert_eq!(persisted.personality, Some(Personality::Pragmatic)); - Ok(()) - } - - #[tokio::test] - async fn skips_when_marker_exists() -> io::Result<()> { - let temp = TempDir::new()?; - create_marker(&temp.path().join(PERSONALITY_MIGRATION_FILENAME)).await?; - - let config_toml = ConfigToml::default(); - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; - - assert_eq!(status, PersonalityMigrationStatus::SkippedMarker); - assert!(!temp.path().join("config.toml").exists()); - Ok(()) - } - - #[tokio::test] - async fn skips_when_personality_explicit() -> io::Result<()> { - let temp = TempDir::new()?; - ConfigEditsBuilder::new(temp.path()) - .set_personality(Some(Personality::Friendly)) - .apply() - .await - .map_err(|err| io::Error::other(format!("failed to write config: {err}")))?; - - let config_toml = read_config_toml(temp.path()).await?; - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; - - assert_eq!( - status, - PersonalityMigrationStatus::SkippedExplicitPersonality - ); - assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); - - let persisted = read_config_toml(temp.path()).await?; - assert_eq!(persisted.personality, Some(Personality::Friendly)); - Ok(()) - } - - #[tokio::test] - async fn skips_when_no_sessions() -> io::Result<()> { - let temp = TempDir::new()?; - let config_toml = ConfigToml::default(); - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; - - assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions); - assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); - assert!(!temp.path().join("config.toml").exists()); - Ok(()) - } -} +#[path = "personality_migration_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs new file mode 100644 index 0000000000..fef1297a97 --- /dev/null +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -0,0 +1,133 @@ +use super::*; +use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::UserMessageEvent; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::io::AsyncWriteExt; + +const TEST_TIMESTAMP: &str = "2025-01-01T00-00-00"; + +async fn read_config_toml(codex_home: &Path) -> io::Result { + let contents = tokio::fs::read_to_string(codex_home.join("config.toml")).await?; + toml::from_str(&contents).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) +} + +async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> { + let thread_id = ThreadId::new(); + let dir = codex_home + .join(SESSIONS_SUBDIR) + .join("2025") + .join("01") + .join("01"); + tokio::fs::create_dir_all(&dir).await?; + let file_path = dir.join(format!("rollout-{TEST_TIMESTAMP}-{thread_id}.jsonl")); + let mut file = tokio::fs::File::create(&file_path).await?; + + let session_meta = SessionMetaLine { + meta: SessionMeta { + id: thread_id, + forked_from_id: None, + timestamp: TEST_TIMESTAMP.to_string(), + cwd: std::path::PathBuf::from("."), + originator: "test_originator".to_string(), + cli_version: "test_version".to_string(), + source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, + model_provider: None, + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }, + git: None, + }; + let meta_line = RolloutLine { + timestamp: TEST_TIMESTAMP.to_string(), + item: RolloutItem::SessionMeta(session_meta), + }; + let user_event = RolloutLine { + timestamp: TEST_TIMESTAMP.to_string(), + item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + }; + + file.write_all(format!("{}\n", serde_json::to_string(&meta_line)?).as_bytes()) + .await?; + file.write_all(format!("{}\n", serde_json::to_string(&user_event)?).as_bytes()) + .await?; + Ok(()) +} + +#[tokio::test] +async fn applies_when_sessions_exist_and_no_personality() -> io::Result<()> { + let temp = TempDir::new()?; + write_session_with_user_event(temp.path()).await?; + + let config_toml = ConfigToml::default(); + let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + + assert_eq!(status, PersonalityMigrationStatus::Applied); + assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); + + let persisted = read_config_toml(temp.path()).await?; + assert_eq!(persisted.personality, Some(Personality::Pragmatic)); + Ok(()) +} + +#[tokio::test] +async fn skips_when_marker_exists() -> io::Result<()> { + let temp = TempDir::new()?; + create_marker(&temp.path().join(PERSONALITY_MIGRATION_FILENAME)).await?; + + let config_toml = ConfigToml::default(); + let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + + assert_eq!(status, PersonalityMigrationStatus::SkippedMarker); + assert!(!temp.path().join("config.toml").exists()); + Ok(()) +} + +#[tokio::test] +async fn skips_when_personality_explicit() -> io::Result<()> { + let temp = TempDir::new()?; + ConfigEditsBuilder::new(temp.path()) + .set_personality(Some(Personality::Friendly)) + .apply() + .await + .map_err(|err| io::Error::other(format!("failed to write config: {err}")))?; + + let config_toml = read_config_toml(temp.path()).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + + assert_eq!( + status, + PersonalityMigrationStatus::SkippedExplicitPersonality + ); + assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); + + let persisted = read_config_toml(temp.path()).await?; + assert_eq!(persisted.personality, Some(Personality::Friendly)); + Ok(()) +} + +#[tokio::test] +async fn skips_when_no_sessions() -> io::Result<()> { + let temp = TempDir::new()?; + let config_toml = ConfigToml::default(); + let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + + assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions); + assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); + assert!(!temp.path().join("config.toml").exists()); + Ok(()) +} diff --git a/codex-rs/core/src/plugins/curated_repo.rs b/codex-rs/core/src/plugins/curated_repo.rs index 3ec492db30..3307f28ffc 100644 --- a/codex-rs/core/src/plugins/curated_repo.rs +++ b/codex-rs/core/src/plugins/curated_repo.rs @@ -1,30 +1,67 @@ +use crate::default_client::build_reqwest_client; +use reqwest::Client; +use serde::Deserialize; use std::fs; +use std::io::Cursor; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::Component; use std::path::Path; use std::path::PathBuf; -use std::process::Command; -use std::process::Output; -use std::process::Stdio; -use std::thread; use std::time::Duration; -use std::time::Instant; +use zip::ZipArchive; -const OPENAI_PLUGINS_REPO_URL: &str = "https://github.com/openai/plugins.git"; +const GITHUB_API_BASE_URL: &str = "https://api.github.com"; +const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json"; +const GITHUB_API_VERSION_HEADER: &str = "2022-11-28"; +const OPENAI_PLUGINS_OWNER: &str = "openai"; +const OPENAI_PLUGINS_REPO: &str = "plugins"; const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; -const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); +const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Deserialize)] +struct GitHubRepositorySummary { + default_branch: String, +} + +#[derive(Debug, Deserialize)] +struct GitHubGitRefSummary { + object: GitHubGitRefObject, +} + +#[derive(Debug, Deserialize)] +struct GitHubGitRefObject { + sha: String, +} pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf { codex_home.join(CURATED_PLUGINS_RELATIVE_DIR) } -pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<(), String> { +pub(crate) fn read_curated_plugins_sha(codex_home: &Path) -> Option { + read_sha_file(codex_home.join(CURATED_PLUGINS_SHA_FILE).as_path()) +} + +pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result { + sync_openai_plugins_repo_with_api_base_url(codex_home, GITHUB_API_BASE_URL) +} + +fn sync_openai_plugins_repo_with_api_base_url( + codex_home: &Path, + api_base_url: &str, +) -> Result { let repo_path = curated_plugins_repo_path(codex_home); let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); - let remote_sha = git_ls_remote_head_sha()?; - let local_sha = read_local_sha(&repo_path, &sha_path); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?; + let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?; + let local_sha = read_sha_file(&sha_path); - if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() { - return Ok(()); + if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() { + return Ok(remote_sha); } let Some(parent) = repo_path.parent() else { @@ -50,23 +87,18 @@ pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<(), String> ) })?; let cloned_repo_path = clone_dir.path().join("repo"); - let clone_output = run_git_command_with_timeout( - Command::new("git") - .env("GIT_OPTIONAL_LOCKS", "0") - .arg("clone") - .arg("--depth") - .arg("1") - .arg(OPENAI_PLUGINS_REPO_URL) - .arg(&cloned_repo_path), - "git clone curated plugins repo", - CURATED_PLUGINS_GIT_TIMEOUT, - )?; - ensure_git_success(&clone_output, "git clone curated plugins repo")?; + let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?; + extract_zipball_to_dir(&zipball_bytes, &cloned_repo_path)?; - let cloned_sha = git_head_sha(&cloned_repo_path)?; - if cloned_sha != remote_sha { + if !cloned_repo_path + .join(".agents/plugins/marketplace.json") + .is_file() + { return Err(format!( - "curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}" + "curated plugins archive missing marketplace manifest at {}", + cloned_repo_path + .join(".agents/plugins/marketplace.json") + .display() )); } @@ -123,231 +155,202 @@ pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<(), String> ) })?; } - fs::write(&sha_path, format!("{cloned_sha}\n")).map_err(|err| { + fs::write(&sha_path, format!("{remote_sha}\n")).map_err(|err| { format!( "failed to write curated plugins sha file {}: {err}", sha_path.display() ) })?; - Ok(()) + Ok(remote_sha) } -fn read_local_sha(repo_path: &Path, sha_path: &Path) -> Option { - if repo_path.join(".git").is_dir() - && let Ok(sha) = git_head_sha(repo_path) - { - return Some(sha); +async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result { + let api_base_url = api_base_url.trim_end_matches('/'); + let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); + let client = build_reqwest_client(); + let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?; + let repo_summary: GitHubRepositorySummary = + serde_json::from_str(&repo_body).map_err(|err| { + format!("failed to parse curated plugins repository response from {repo_url}: {err}") + })?; + if repo_summary.default_branch.is_empty() { + return Err(format!( + "curated plugins repository response from {repo_url} did not include a default branch" + )); } + let git_ref_url = format!("{repo_url}/git/ref/heads/{}", repo_summary.default_branch); + let git_ref_body = + fetch_github_text(&client, &git_ref_url, "get curated plugins HEAD ref").await?; + let git_ref: GitHubGitRefSummary = serde_json::from_str(&git_ref_body).map_err(|err| { + format!("failed to parse curated plugins ref response from {git_ref_url}: {err}") + })?; + if git_ref.object.sha.is_empty() { + return Err(format!( + "curated plugins ref response from {git_ref_url} did not include a HEAD sha" + )); + } + + Ok(git_ref.object.sha) +} + +async fn fetch_curated_repo_zipball( + api_base_url: &str, + remote_sha: &str, +) -> Result, String> { + let api_base_url = api_base_url.trim_end_matches('/'); + let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); + let zipball_url = format!("{repo_url}/zipball/{remote_sha}"); + let client = build_reqwest_client(); + fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await +} + +async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result { + let response = github_request(client, url) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!( + "{context} from {url} failed with status {status}: {body}" + )); + } + Ok(body) +} + +async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result, String> { + let response = github_request(client, url) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|err| format!("failed to read {context} response from {url}: {err}"))?; + if !status.is_success() { + let body_text = String::from_utf8_lossy(&body); + return Err(format!( + "{context} from {url} failed with status {status}: {body_text}" + )); + } + Ok(body.to_vec()) +} + +fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder { + client + .get(url) + .timeout(CURATED_PLUGINS_HTTP_TIMEOUT) + .header("accept", GITHUB_API_ACCEPT_HEADER) + .header("x-github-api-version", GITHUB_API_VERSION_HEADER) +} + +fn read_sha_file(sha_path: &Path) -> Option { fs::read_to_string(sha_path) .ok() .map(|sha| sha.trim().to_string()) .filter(|sha| !sha.is_empty()) } -fn git_ls_remote_head_sha() -> Result { - let output = run_git_command_with_timeout( - Command::new("git") - .env("GIT_OPTIONAL_LOCKS", "0") - .arg("ls-remote") - .arg(OPENAI_PLUGINS_REPO_URL) - .arg("HEAD"), - "git ls-remote curated plugins repo", - CURATED_PLUGINS_GIT_TIMEOUT, - )?; - ensure_git_success(&output, "git ls-remote curated plugins repo")?; +fn extract_zipball_to_dir(bytes: &[u8], destination: &Path) -> Result<(), String> { + fs::create_dir_all(destination).map_err(|err| { + format!( + "failed to create curated plugins extraction directory {}: {err}", + destination.display() + ) + })?; - let stdout = String::from_utf8_lossy(&output.stdout); - let Some(first_line) = stdout.lines().next() else { - return Err("git ls-remote returned empty output for curated plugins repo".to_string()); - }; - let Some((sha, _)) = first_line.split_once('\t') else { - return Err(format!( - "unexpected git ls-remote output for curated plugins repo: {first_line}" - )); - }; - if sha.is_empty() { - return Err("git ls-remote returned empty sha for curated plugins repo".to_string()); - } - Ok(sha.to_string()) -} + let cursor = Cursor::new(bytes); + let mut archive = ZipArchive::new(cursor) + .map_err(|err| format!("failed to open curated plugins zip archive: {err}"))?; -fn git_head_sha(repo_path: &Path) -> Result { - let output = Command::new("git") - .env("GIT_OPTIONAL_LOCKS", "0") - .arg("-C") - .arg(repo_path) - .arg("rev-parse") - .arg("HEAD") - .output() - .map_err(|err| { + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|err| format!("failed to read curated plugins zip entry: {err}"))?; + let Some(relative_path) = entry.enclosed_name() else { + return Err(format!( + "curated plugins zip entry `{}` escapes extraction root", + entry.name() + )); + }; + + let mut components = relative_path.components(); + let Some(Component::Normal(_)) = components.next() else { + continue; + }; + + let output_relative = components.fold(PathBuf::new(), |mut path, component| { + if let Component::Normal(segment) = component { + path.push(segment); + } + path + }); + if output_relative.as_os_str().is_empty() { + continue; + } + + let output_path = destination.join(&output_relative); + if entry.is_dir() { + fs::create_dir_all(&output_path).map_err(|err| { + format!( + "failed to create curated plugins directory {}: {err}", + output_path.display() + ) + })?; + continue; + } + + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins directory {}: {err}", + parent.display() + ) + })?; + } + let mut output = fs::File::create(&output_path).map_err(|err| { format!( - "failed to run git rev-parse HEAD in {}: {err}", - repo_path.display() + "failed to create curated plugins file {}: {err}", + output_path.display() ) })?; - ensure_git_success(&output, "git rev-parse HEAD")?; - - let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if sha.is_empty() { - return Err(format!( - "git rev-parse HEAD returned empty output in {}", - repo_path.display() - )); + std::io::copy(&mut entry, &mut output).map_err(|err| { + format!( + "failed to write curated plugins file {}: {err}", + output_path.display() + ) + })?; + apply_zip_permissions(&entry, &output_path)?; } - Ok(sha) + + Ok(()) } -fn run_git_command_with_timeout( - command: &mut Command, - context: &str, - timeout: Duration, -) -> Result { - let mut child = command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|err| format!("failed to run {context}: {err}"))?; - - let start = Instant::now(); - loop { - match child.try_wait() { - Ok(Some(_)) => { - return child - .wait_with_output() - .map_err(|err| format!("failed to wait for {context}: {err}")); - } - Ok(None) => {} - Err(err) => return Err(format!("failed to poll {context}: {err}")), - } - - if start.elapsed() >= timeout { - match child.try_wait() { - Ok(Some(_)) => { - return child - .wait_with_output() - .map_err(|err| format!("failed to wait for {context}: {err}")); - } - Ok(None) => {} - Err(err) => return Err(format!("failed to poll {context}: {err}")), - } - - let _ = child.kill(); - let output = child - .wait_with_output() - .map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?; - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return if stderr.is_empty() { - Err(format!("{context} timed out after {}s", timeout.as_secs())) - } else { - Err(format!( - "{context} timed out after {}s: {stderr}", - timeout.as_secs() - )) - }; - } - - thread::sleep(Duration::from_millis(100)); - } -} - -fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> { - if output.status.success() { +#[cfg(unix)] +fn apply_zip_permissions(entry: &zip::read::ZipFile<'_>, output_path: &Path) -> Result<(), String> { + let Some(mode) = entry.unix_mode() else { return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if stderr.is_empty() { - Err(format!("{context} failed with status {}", output.status)) - } else { - Err(format!( - "{context} failed with status {}: {stderr}", - output.status - )) - } + }; + fs::set_permissions(output_path, fs::Permissions::from_mode(mode)).map_err(|err| { + format!( + "failed to set permissions on curated plugins file {}: {err}", + output_path.display() + ) + }) +} + +#[cfg(not(unix))] +fn apply_zip_permissions( + _entry: &zip::read::ZipFile<'_>, + _output_path: &Path, +) -> Result<(), String> { + Ok(()) } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::tempdir; - - #[test] - fn curated_plugins_repo_path_uses_codex_home_tmp_dir() { - let tmp = tempdir().expect("tempdir"); - assert_eq!( - curated_plugins_repo_path(tmp.path()), - tmp.path().join(".tmp/plugins") - ); - } - - #[test] - fn read_local_sha_prefers_repo_head_when_available() { - let tmp = tempdir().expect("tempdir"); - let repo_path = tmp.path().join("repo"); - let sha_path = tmp.path().join("plugins.sha"); - - fs::create_dir_all(&repo_path).expect("create repo dir"); - fs::write(&sha_path, "abc123\n").expect("write sha"); - let init_output = Command::new("git") - .arg("init") - .arg(&repo_path) - .output() - .expect("git init should run"); - ensure_git_success(&init_output, "git init").expect("git init should succeed"); - let config_name_output = Command::new("git") - .arg("-C") - .arg(&repo_path) - .arg("config") - .arg("user.name") - .arg("Codex") - .output() - .expect("git config user.name should run"); - ensure_git_success(&config_name_output, "git config user.name") - .expect("git config user.name should succeed"); - let config_email_output = Command::new("git") - .arg("-C") - .arg(&repo_path) - .arg("config") - .arg("user.email") - .arg("codex@example.com") - .output() - .expect("git config user.email should run"); - ensure_git_success(&config_email_output, "git config user.email") - .expect("git config user.email should succeed"); - fs::write(repo_path.join("README.md"), "demo\n").expect("write file"); - let add_output = Command::new("git") - .arg("-C") - .arg(&repo_path) - .arg("add") - .arg(".") - .output() - .expect("git add should run"); - ensure_git_success(&add_output, "git add").expect("git add should succeed"); - let commit_output = Command::new("git") - .arg("-C") - .arg(&repo_path) - .arg("commit") - .arg("-m") - .arg("init") - .output() - .expect("git commit should run"); - ensure_git_success(&commit_output, "git commit").expect("git commit should succeed"); - - let sha = read_local_sha(&repo_path, &sha_path); - assert_eq!(sha, Some(git_head_sha(&repo_path).expect("repo head sha"))); - } - - #[test] - fn read_local_sha_falls_back_to_sha_file() { - let tmp = tempdir().expect("tempdir"); - let repo_path = tmp.path().join("repo"); - let sha_path = tmp.path().join("plugins.sha"); - fs::write(&sha_path, "abc123\n").expect("write sha"); - - let sha = read_local_sha(&repo_path, &sha_path); - assert_eq!(sha.as_deref(), Some("abc123")); - } -} +#[path = "curated_repo_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/plugins/curated_repo_tests.rs b/codex-rs/core/src/plugins/curated_repo_tests.rs new file mode 100644 index 0000000000..5a14124d06 --- /dev/null +++ b/codex-rs/core/src/plugins/curated_repo_tests.rs @@ -0,0 +1,159 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::io::Write; +use tempfile::tempdir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +#[test] +fn curated_plugins_repo_path_uses_codex_home_tmp_dir() { + let tmp = tempdir().expect("tempdir"); + assert_eq!( + curated_plugins_repo_path(tmp.path()), + tmp.path().join(".tmp/plugins") + ); +} + +#[test] +fn read_curated_plugins_sha_reads_trimmed_sha_file() { + let tmp = tempdir().expect("tempdir"); + fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp"); + fs::write(tmp.path().join(".tmp/plugins.sha"), "abc123\n").expect("write sha"); + + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some("abc123") + ); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_downloads_zipball_and_records_sha() { + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let sha = "0123456789abcdef0123456789abcdef01234567"; + + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins/git/ref/heads/main")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/repos/openai/plugins/zipball/{sha}"))) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/zip") + .set_body_bytes(curated_repo_zipball_bytes(sha)), + ) + .mount(&server) + .await; + + let server_uri = server.uri(); + let tmp_path = tmp.path().to_path_buf(); + tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_with_api_base_url(tmp_path.as_path(), &server_uri) + }) + .await + .expect("sync task should join") + .expect("sync should succeed"); + + let repo_path = curated_plugins_repo_path(tmp.path()); + assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); + assert!( + repo_path + .join("plugins/gmail/.codex-plugin/plugin.json") + .is_file() + ); + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() { + let tmp = tempdir().expect("tempdir"); + let repo_path = curated_plugins_repo_path(tmp.path()); + fs::create_dir_all(repo_path.join(".agents/plugins")).expect("create repo"); + fs::write( + repo_path.join(".agents/plugins/marketplace.json"), + r#"{"name":"openai-curated","plugins":[]}"#, + ) + .expect("write marketplace"); + fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp"); + let sha = "fedcba9876543210fedcba9876543210fedcba98"; + fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha"); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins/git/ref/heads/main")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), + ) + .mount(&server) + .await; + + let server_uri = server.uri(); + let tmp_path = tmp.path().to_path_buf(); + tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_with_api_base_url(tmp_path.as_path(), &server_uri) + }) + .await + .expect("sync task should join") + .expect("sync should succeed"); + + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); + assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); +} + +fn curated_repo_zipball_bytes(sha: &str) -> Vec { + let cursor = Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(cursor); + let options = SimpleFileOptions::default(); + let root = format!("openai-plugins-{sha}"); + writer + .start_file(format!("{root}/.agents/plugins/marketplace.json"), options) + .expect("start marketplace entry"); + writer + .write_all( + br#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail" + } + } + ] +}"#, + ) + .expect("write marketplace"); + writer + .start_file( + format!("{root}/plugins/gmail/.codex-plugin/plugin.json"), + options, + ) + .expect("start plugin manifest entry"); + writer + .write_all(br#"{"name":"gmail"}"#) + .expect("write plugin manifest"); + + writer.finish().expect("finish zip writer").into_inner() +} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 770512b2f8..032437c199 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -11,6 +11,7 @@ use super::marketplace::load_marketplace_summary; use super::marketplace::resolve_marketplace_plugin; use super::plugin_manifest_name; use super::plugin_manifest_paths; +use super::read_curated_plugins_sha; use super::store::DEFAULT_PLUGIN_VERSION; use super::store::PluginId; use super::store::PluginIdError; @@ -45,6 +46,7 @@ use std::collections::HashSet; use std::fs; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -56,7 +58,6 @@ use tracing::warn; const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; -const DISABLE_CURATED_PLUGIN_SYNC_ENV_VAR: &str = "CODEX_DISABLE_CURATED_PLUGIN_SYNC"; const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; const REMOTE_PLUGIN_SYNC_TIMEOUT: Duration = Duration::from_secs(30); static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); @@ -75,7 +76,7 @@ pub struct PluginInstallOutcome { pub plugin_id: PluginId, pub plugin_version: String, pub installed_path: AbsolutePathBuf, - pub auth_policy: Option, + pub auth_policy: MarketplacePluginAuthPolicy, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -90,8 +91,8 @@ pub struct ConfiguredMarketplacePluginSummary { pub id: String, pub name: String, pub source: MarketplacePluginSourceSummary, - pub install_policy: Option, - pub auth_policy: Option, + pub install_policy: MarketplacePluginInstallPolicy, + pub auth_policy: MarketplacePluginAuthPolicy, pub interface: Option, pub installed: bool, pub enabled: bool, @@ -395,9 +396,25 @@ impl PluginsManager { ) -> Result { let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?; let auth_policy = resolved.auth_policy; + let plugin_version = + if resolved.plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { + Some( + read_curated_plugins_sha(self.codex_home.as_path()).ok_or_else(|| { + PluginStoreError::Invalid( + "local curated marketplace sha is not available".to_string(), + ) + })?, + ) + } else { + None + }; let store = self.store.clone(); let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || { - store.install(resolved.source_path, resolved.plugin_id) + if let Some(plugin_version) = plugin_version { + store.install_with_version(resolved.source_path, resolved.plugin_id, plugin_version) + } else { + store.install(resolved.source_path, resolved.plugin_id) + } }) .await .map_err(PluginInstallError::join)??; @@ -464,8 +481,19 @@ impl PluginsManager { }; let marketplace_name = curated_marketplace.name.clone(); - let mut local_plugins = - Vec::<(String, PluginId, AbsolutePathBuf, Option, bool)>::new(); + let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path()) + .ok_or_else(|| { + PluginStoreError::Invalid( + "local curated marketplace sha is not available".to_string(), + ) + })?; + let mut local_plugins = Vec::<( + String, + PluginId, + AbsolutePathBuf, + Option, + Option, + )>::new(); let mut local_plugin_names = HashSet::new(); for plugin in curated_marketplace.plugins { let plugin_name = plugin.name; @@ -486,13 +514,13 @@ impl PluginsManager { let current_enabled = configured_plugins .get(&plugin_key) .map(|plugin| plugin.enabled); - let is_installed = self.store.is_installed(&plugin_id); + let installed_version = self.store.active_plugin_version(&plugin_id); local_plugins.push(( plugin_name, plugin_id, source_path, current_enabled, - is_installed, + installed_version, )); } @@ -528,11 +556,20 @@ impl PluginsManager { let remote_plugin_count = remote_enabled_by_name.len(); let local_plugin_count = local_plugins.len(); - for (plugin_name, plugin_id, source_path, current_enabled, is_installed) in local_plugins { + for (plugin_name, plugin_id, source_path, current_enabled, installed_version) in + local_plugins + { let plugin_key = plugin_id.as_key(); + let is_installed = installed_version.is_some(); if let Some(enabled) = remote_enabled_by_name.get(&plugin_name).copied() { if !is_installed { - installs.push((source_path, plugin_id.clone())); + installs.push(( + source_path, + plugin_id.clone(), + curated_plugin_version.clone(), + )); + } + if !is_installed { result.installed_plugin_ids.push(plugin_key.clone()); } @@ -565,8 +602,8 @@ impl PluginsManager { let store = self.store.clone(); let store_result = tokio::task::spawn_blocking(move || { - for (source_path, plugin_id) in installs { - store.install(source_path, plugin_id)?; + for (source_path, plugin_id, plugin_version) in installs { + store.install_with_version(source_path, plugin_id, plugin_version)?; } for plugin_id in uninstalls { store.uninstall(&plugin_id)?; @@ -668,28 +705,67 @@ impl PluginsManager { .collect()) } - pub fn maybe_start_curated_repo_sync_for_config(&self, config: &Config) { + pub fn maybe_start_curated_repo_sync_for_config(self: &Arc, config: &Config) { if plugins_feature_enabled_from_stack(&config.config_layer_stack) { - self.start_curated_repo_sync(); + let mut configured_curated_plugin_ids = + configured_plugins_from_stack(&config.config_layer_stack) + .into_keys() + .filter_map(|plugin_key| match PluginId::parse(&plugin_key) { + Ok(plugin_id) + if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME => + { + Some(plugin_id) + } + Ok(_) => None, + Err(err) => { + warn!( + plugin_key, + error = %err, + "ignoring invalid configured plugin key during curated sync setup" + ); + None + } + }) + .collect::>(); + configured_curated_plugin_ids.sort_unstable_by_key(super::store::PluginId::as_key); + self.start_curated_repo_sync(configured_curated_plugin_ids); } } - pub fn start_curated_repo_sync(&self) { - if std::env::var_os(DISABLE_CURATED_PLUGIN_SYNC_ENV_VAR).is_some() { - return; - } + fn start_curated_repo_sync(self: &Arc, configured_curated_plugin_ids: Vec) { if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) { return; } + let manager = Arc::clone(self); let codex_home = self.codex_home.clone(); if let Err(err) = std::thread::Builder::new() .name("plugins-curated-repo-sync".to_string()) - .spawn(move || { - if let Err(err) = sync_openai_plugins_repo(codex_home.as_path()) { - CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); - warn!("failed to sync curated plugins repo: {err}"); - } - }) + .spawn( + move || match sync_openai_plugins_repo(codex_home.as_path()) { + Ok(curated_plugin_version) => { + match refresh_curated_plugin_cache( + codex_home.as_path(), + &curated_plugin_version, + &configured_curated_plugin_ids, + ) { + Ok(cache_refreshed) => { + if cache_refreshed { + manager.clear_cache(); + } + } + Err(err) => { + manager.clear_cache(); + CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); + warn!("failed to refresh curated plugin cache after sync: {err}"); + } + } + } + Err(err) => { + CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); + warn!("failed to sync curated plugins repo: {err}"); + } + }, + ) { CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); warn!("failed to start curated plugins repo sync task: {err}"); @@ -906,6 +982,65 @@ pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option { None } +fn refresh_curated_plugin_cache( + codex_home: &Path, + plugin_version: &str, + configured_curated_plugin_ids: &[PluginId], +) -> Result { + let store = PluginStore::new(codex_home.to_path_buf()); + let curated_marketplace_path = AbsolutePathBuf::try_from( + curated_plugins_repo_path(codex_home).join(".agents/plugins/marketplace.json"), + ) + .map_err(|_| "local curated marketplace is not available".to_string())?; + let curated_marketplace = load_marketplace_summary(&curated_marketplace_path) + .map_err(|err| format!("failed to load curated marketplace for cache refresh: {err}"))?; + + let mut plugin_sources = HashMap::::new(); + for plugin in curated_marketplace.plugins { + let plugin_name = plugin.name; + if plugin_sources.contains_key(&plugin_name) { + warn!( + plugin = plugin_name, + marketplace = OPENAI_CURATED_MARKETPLACE_NAME, + "ignoring duplicate curated plugin entry during cache refresh" + ); + continue; + } + let source_path = match plugin.source { + MarketplacePluginSourceSummary::Local { path } => path, + }; + plugin_sources.insert(plugin_name, source_path); + } + + let mut cache_refreshed = false; + for plugin_id in configured_curated_plugin_ids { + if store.active_plugin_version(plugin_id).as_deref() == Some(plugin_version) { + continue; + } + + let Some(source_path) = plugin_sources.get(&plugin_id.plugin_name).cloned() else { + warn!( + plugin = plugin_id.plugin_name, + marketplace = OPENAI_CURATED_MARKETPLACE_NAME, + "configured curated plugin no longer exists in curated marketplace during cache refresh" + ); + continue; + }; + + store + .install_with_version(source_path, plugin_id.clone(), plugin_version.to_string()) + .map_err(|err| { + format!( + "failed to refresh curated plugin cache for {}: {err}", + plugin_id.as_key() + ) + })?; + cache_refreshed = true; + } + + Ok(cache_refreshed) +} + fn configured_plugins_from_stack( config_layer_stack: &ConfigLayerStack, ) -> HashMap { @@ -926,9 +1061,11 @@ fn configured_plugins_from_stack( } fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) -> LoadedPlugin { - let plugin_version = DEFAULT_PLUGIN_VERSION.to_string(); - let plugin_root = PluginId::parse(&config_name) - .map(|plugin_id| store.plugin_root(&plugin_id, &plugin_version)); + let plugin_root = PluginId::parse(&config_name).map(|plugin_id| { + store + .active_plugin_root(&plugin_id) + .unwrap_or_else(|| store.plugin_root(&plugin_id, DEFAULT_PLUGIN_VERSION)) + }); let root = match &plugin_root { Ok(plugin_root) => plugin_root.clone(), Err(_) => store.root().clone(), @@ -1205,1550 +1342,5 @@ struct PluginMcpDiscovery { } #[cfg(test)] -mod tests { - use super::*; - use crate::auth::CodexAuth; - use crate::config::CONFIG_TOML_FILE; - use crate::config::ConfigBuilder; - use crate::config::types::McpServerTransportConfig; - use crate::config_loader::ConfigLayerEntry; - use crate::config_loader::ConfigLayerStack; - use crate::config_loader::ConfigRequirements; - use crate::config_loader::ConfigRequirementsToml; - use codex_app_server_protocol::ConfigLayerSource; - use pretty_assertions::assert_eq; - use std::fs; - use tempfile::TempDir; - use toml::Value; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - fn write_file(path: &Path, contents: &str) { - fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); - fs::write(path, contents).unwrap(); - } - - fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { - let plugin_root = root.join(dir_name); - fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); - fs::create_dir_all(plugin_root.join("skills")).unwrap(); - fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - format!(r#"{{"name":"{manifest_name}"}}"#), - ) - .unwrap(); - fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap(); - fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); - } - - fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { - fs::create_dir_all(root.join(".git")).unwrap(); - fs::create_dir_all(root.join(".agents/plugins")).unwrap(); - let plugins = plugin_names - .iter() - .map(|plugin_name| { - format!( - r#"{{ - "name": "{plugin_name}", - "source": {{ - "source": "local", - "path": "./plugins/{plugin_name}" - }} - }}"# - ) - }) - .collect::>() - .join(",\n"); - fs::write( - root.join(".agents/plugins/marketplace.json"), - format!( - r#"{{ - "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", - "plugins": [ -{plugins} - ] -}}"# - ), - ) - .unwrap(); - for plugin_name in plugin_names { - write_plugin(root, &format!("plugins/{plugin_name}"), plugin_name); - } - } - - fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { - let mut root = toml::map::Map::new(); - - let mut features = toml::map::Map::new(); - features.insert( - "plugins".to_string(), - Value::Boolean(plugins_feature_enabled), - ); - root.insert("features".to_string(), Value::Table(features)); - - let mut plugin = toml::map::Map::new(); - plugin.insert("enabled".to_string(), Value::Boolean(enabled)); - - let mut plugins = toml::map::Map::new(); - plugins.insert("sample@test".to_string(), Value::Table(plugin)); - root.insert("plugins".to_string(), Value::Table(plugins)); - - toml::to_string(&Value::Table(root)).expect("plugin test config should serialize") - } - - fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> PluginLoadOutcome { - write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml); - let stack = ConfigLayerStack::new( - vec![ConfigLayerEntry::new( - ConfigLayerSource::User { - file: AbsolutePathBuf::try_from(codex_home.join(CONFIG_TOML_FILE)).unwrap(), - }, - toml::from_str(config_toml).expect("plugin test config should parse"), - )], - ConfigRequirements::default(), - ConfigRequirementsToml::default(), - ) - .expect("config layer stack should build"); - PluginsManager::new(codex_home.to_path_buf()) - .plugins_for_layer_stack(codex_home, &stack, false) - } - - async fn load_config(codex_home: &Path, cwd: &Path) -> crate::config::Config { - ConfigBuilder::default() - .codex_home(codex_home.to_path_buf()) - .fallback_cwd(Some(cwd.to_path_buf())) - .build() - .await - .expect("config should load") - } - - #[test] - fn load_plugins_loads_default_skills_and_mcp_servers() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); - - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{ - "name": "sample", - "description": "Plugin that includes the sample MCP server and Skills" -}"#, - ); - write_file( - &plugin_root.join("skills/sample-search/SKILL.md"), - "---\nname: sample-search\ndescription: search sample data\n---\n", - ); - write_file( - &plugin_root.join(".mcp.json"), - r#"{ - "mcpServers": { - "sample": { - "type": "http", - "url": "https://sample.example/mcp", - "oauth": { - "clientId": "client-id", - "callbackPort": 3118 - } - } - } -}"#, - ); - write_file( - &plugin_root.join(".app.json"), - r#"{ - "apps": { - "example": { - "id": "connector_example" - } - } -}"#, - ); - - let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); - - assert_eq!( - outcome.plugins, - vec![LoadedPlugin { - config_name: "sample@test".to_string(), - manifest_name: Some("sample".to_string()), - manifest_description: Some( - "Plugin that includes the sample MCP server and Skills".to_string(), - ), - root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), - enabled: true, - skill_roots: vec![plugin_root.join("skills")], - mcp_servers: HashMap::from([( - "sample".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://sample.example/mcp".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - )]), - apps: vec![AppConnectorId("connector_example".to_string())], - error: None, - }] - ); - assert_eq!( - outcome.capability_summaries(), - &[PluginCapabilitySummary { - config_name: "sample@test".to_string(), - display_name: "sample".to_string(), - description: Some( - "Plugin that includes the sample MCP server and Skills".to_string(), - ), - has_skills: true, - mcp_server_names: vec!["sample".to_string()], - app_connector_ids: vec![AppConnectorId("connector_example".to_string())], - }] - ); - assert_eq!( - outcome.effective_skill_roots(), - vec![plugin_root.join("skills")] - ); - assert_eq!(outcome.effective_mcp_servers().len(), 1); - assert_eq!( - outcome.effective_apps(), - vec![AppConnectorId("connector_example".to_string())] - ); - } - - #[test] - fn load_plugins_uses_manifest_configured_component_paths() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); - - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{ - "name": "sample", - "skills": "./custom-skills/", - "mcpServers": "./config/custom.mcp.json", - "apps": "./config/custom.app.json" -}"#, - ); - write_file( - &plugin_root.join("skills/default-skill/SKILL.md"), - "---\nname: default-skill\ndescription: default skill\n---\n", - ); - write_file( - &plugin_root.join("custom-skills/custom-skill/SKILL.md"), - "---\nname: custom-skill\ndescription: custom skill\n---\n", - ); - write_file( - &plugin_root.join(".mcp.json"), - r#"{ - "mcpServers": { - "default": { - "type": "http", - "url": "https://default.example/mcp" - } - } -}"#, - ); - write_file( - &plugin_root.join("config/custom.mcp.json"), - r#"{ - "mcpServers": { - "custom": { - "type": "http", - "url": "https://custom.example/mcp" - } - } -}"#, - ); - write_file( - &plugin_root.join(".app.json"), - r#"{ - "apps": { - "default": { - "id": "connector_default" - } - } -}"#, - ); - write_file( - &plugin_root.join("config/custom.app.json"), - r#"{ - "apps": { - "custom": { - "id": "connector_custom" - } - } -}"#, - ); - - let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); - - assert_eq!( - outcome.plugins[0].skill_roots, - vec![ - plugin_root.join("custom-skills"), - plugin_root.join("skills") - ] - ); - assert_eq!( - outcome.plugins[0].mcp_servers, - HashMap::from([( - "custom".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://custom.example/mcp".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - )]) - ); - assert_eq!( - outcome.plugins[0].apps, - vec![AppConnectorId("connector_custom".to_string())] - ); - } - - #[test] - fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); - - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{ - "name": "sample", - "skills": "custom-skills", - "mcpServers": "config/custom.mcp.json", - "apps": "config/custom.app.json" -}"#, - ); - write_file( - &plugin_root.join("skills/default-skill/SKILL.md"), - "---\nname: default-skill\ndescription: default skill\n---\n", - ); - write_file( - &plugin_root.join("custom-skills/custom-skill/SKILL.md"), - "---\nname: custom-skill\ndescription: custom skill\n---\n", - ); - write_file( - &plugin_root.join(".mcp.json"), - r#"{ - "mcpServers": { - "default": { - "type": "http", - "url": "https://default.example/mcp" - } - } -}"#, - ); - write_file( - &plugin_root.join("config/custom.mcp.json"), - r#"{ - "mcpServers": { - "custom": { - "type": "http", - "url": "https://custom.example/mcp" - } - } -}"#, - ); - write_file( - &plugin_root.join(".app.json"), - r#"{ - "apps": { - "default": { - "id": "connector_default" - } - } -}"#, - ); - write_file( - &plugin_root.join("config/custom.app.json"), - r#"{ - "apps": { - "custom": { - "id": "connector_custom" - } - } -}"#, - ); - - let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); - - assert_eq!( - outcome.plugins[0].skill_roots, - vec![plugin_root.join("skills")] - ); - assert_eq!( - outcome.plugins[0].mcp_servers, - HashMap::from([( - "default".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://default.example/mcp".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }, - )]) - ); - assert_eq!( - outcome.plugins[0].apps, - vec![AppConnectorId("connector_default".to_string())] - ); - } - - #[test] - fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); - - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ); - write_file( - &plugin_root.join(".mcp.json"), - r#"{ - "mcpServers": { - "sample": { - "type": "http", - "url": "https://sample.example/mcp" - } - } -}"#, - ); - - let outcome = load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path()); - - assert_eq!( - outcome.plugins, - vec![LoadedPlugin { - config_name: "sample@test".to_string(), - manifest_name: None, - manifest_description: None, - root: AbsolutePathBuf::try_from(plugin_root).unwrap(), - enabled: false, - skill_roots: Vec::new(), - mcp_servers: HashMap::new(), - apps: Vec::new(), - error: None, - }] - ); - assert!(outcome.effective_skill_roots().is_empty()); - assert!(outcome.effective_mcp_servers().is_empty()); - } - - #[test] - fn effective_apps_dedupes_connector_ids_across_plugins() { - let codex_home = TempDir::new().unwrap(); - let plugin_a_root = codex_home - .path() - .join("plugins/cache") - .join("test/plugin-a/local"); - let plugin_b_root = codex_home - .path() - .join("plugins/cache") - .join("test/plugin-b/local"); - - write_file( - &plugin_a_root.join(".codex-plugin/plugin.json"), - r#"{"name":"plugin-a"}"#, - ); - write_file( - &plugin_a_root.join(".app.json"), - r#"{ - "apps": { - "example": { - "id": "connector_example" - } - } -}"#, - ); - write_file( - &plugin_b_root.join(".codex-plugin/plugin.json"), - r#"{"name":"plugin-b"}"#, - ); - write_file( - &plugin_b_root.join(".app.json"), - r#"{ - "apps": { - "chat": { - "id": "connector_example" - }, - "gmail": { - "id": "connector_gmail" - } - } -}"#, - ); - - let mut root = toml::map::Map::new(); - let mut features = toml::map::Map::new(); - features.insert("plugins".to_string(), Value::Boolean(true)); - root.insert("features".to_string(), Value::Table(features)); - - let mut plugins = toml::map::Map::new(); - - let mut plugin_a = toml::map::Map::new(); - plugin_a.insert("enabled".to_string(), Value::Boolean(true)); - plugins.insert("plugin-a@test".to_string(), Value::Table(plugin_a)); - - let mut plugin_b = toml::map::Map::new(); - plugin_b.insert("enabled".to_string(), Value::Boolean(true)); - plugins.insert("plugin-b@test".to_string(), Value::Table(plugin_b)); - - root.insert("plugins".to_string(), Value::Table(plugins)); - let config_toml = - toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"); - - let outcome = load_plugins_from_config(&config_toml, codex_home.path()); - - assert_eq!( - outcome.effective_apps(), - vec![ - AppConnectorId("connector_example".to_string()), - AppConnectorId("connector_gmail".to_string()), - ] - ); - } - - #[test] - fn capability_index_filters_inactive_and_zero_capability_plugins() { - let codex_home = TempDir::new().unwrap(); - let connector = |id: &str| AppConnectorId(id.to_string()); - let http_server = |url: &str| McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: url.to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }; - let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin { - config_name: config_name.to_string(), - manifest_name: Some(manifest_name.to_string()), - manifest_description: None, - root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), - enabled: true, - skill_roots: Vec::new(), - mcp_servers: HashMap::new(), - apps: Vec::new(), - error: None, - }; - let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary { - config_name: config_name.to_string(), - display_name: display_name.to_string(), - description: None, - ..PluginCapabilitySummary::default() - }; - let outcome = PluginLoadOutcome::from_plugins(vec![ - LoadedPlugin { - skill_roots: vec![codex_home.path().join("skills-plugin/skills")], - ..plugin("skills@test", "skills-plugin", "skills-plugin") - }, - LoadedPlugin { - mcp_servers: HashMap::from([("alpha".to_string(), http_server("https://alpha"))]), - apps: vec![connector("connector_example")], - ..plugin("alpha@test", "alpha-plugin", "alpha-plugin") - }, - LoadedPlugin { - mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]), - apps: vec![connector("connector_example"), connector("connector_gmail")], - ..plugin("beta@test", "beta-plugin", "beta-plugin") - }, - plugin("empty@test", "empty-plugin", "empty-plugin"), - LoadedPlugin { - enabled: false, - skill_roots: vec![codex_home.path().join("disabled-plugin/skills")], - apps: vec![connector("connector_hidden")], - ..plugin("disabled@test", "disabled-plugin", "disabled-plugin") - }, - LoadedPlugin { - apps: vec![connector("connector_broken")], - error: Some("failed to load".to_string()), - ..plugin("broken@test", "broken-plugin", "broken-plugin") - }, - ]); - - assert_eq!( - outcome.capability_summaries(), - &[ - PluginCapabilitySummary { - has_skills: true, - ..summary("skills@test", "skills-plugin") - }, - PluginCapabilitySummary { - mcp_server_names: vec!["alpha".to_string()], - app_connector_ids: vec![connector("connector_example")], - ..summary("alpha@test", "alpha-plugin") - }, - PluginCapabilitySummary { - mcp_server_names: vec!["beta".to_string()], - app_connector_ids: vec![ - connector("connector_example"), - connector("connector_gmail"), - ], - ..summary("beta@test", "beta-plugin") - }, - ] - ); - } - - #[test] - fn plugin_namespace_for_skill_path_uses_manifest_name() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home.path().join("plugins/sample"); - let skill_path = plugin_root.join("skills/search/SKILL.md"); - - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ); - write_file(&skill_path, "---\ndescription: search\n---\n"); - - assert_eq!( - plugin_namespace_for_skill_path(&skill_path), - Some("sample".to_string()) - ); - } - - #[test] - fn load_plugins_returns_empty_when_feature_disabled() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); - - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ); - write_file( - &plugin_root.join("skills/sample-search/SKILL.md"), - "---\nname: sample-search\ndescription: search sample data\n---\n", - ); - - let outcome = load_plugins_from_config(&plugin_config_toml(true, false), codex_home.path()); - - assert_eq!(outcome, PluginLoadOutcome::default()); - } - - #[test] - fn load_plugins_rejects_invalid_plugin_keys() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); - - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ); - - let mut root = toml::map::Map::new(); - let mut features = toml::map::Map::new(); - features.insert("plugins".to_string(), Value::Boolean(true)); - root.insert("features".to_string(), Value::Table(features)); - - let mut plugin = toml::map::Map::new(); - plugin.insert("enabled".to_string(), Value::Boolean(true)); - - let mut plugins = toml::map::Map::new(); - plugins.insert("sample".to_string(), Value::Table(plugin)); - root.insert("plugins".to_string(), Value::Table(plugins)); - - let outcome = load_plugins_from_config( - &toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"), - codex_home.path(), - ); - - assert_eq!(outcome.plugins.len(), 1); - assert_eq!( - outcome.plugins[0].error.as_deref(), - Some("invalid plugin key `sample`; expected @") - ); - assert!(outcome.effective_skill_roots().is_empty()); - assert!(outcome.effective_mcp_servers().is_empty()); - } - - #[tokio::test] - async fn install_plugin_updates_config_with_relative_path_and_plugin_key() { - let tmp = tempfile::tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - write_plugin(&repo_root, "sample-plugin", "sample-plugin"); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "debug", - "plugins": [ - { - "name": "sample-plugin", - "source": { - "source": "local", - "path": "./sample-plugin" - }, - "authPolicy": "ON_USE" - } - ] -}"#, - ) - .unwrap(); - - let result = PluginsManager::new(tmp.path().to_path_buf()) - .install_plugin(PluginInstallRequest { - plugin_name: "sample-plugin".to_string(), - marketplace_path: AbsolutePathBuf::try_from( - repo_root.join(".agents/plugins/marketplace.json"), - ) - .unwrap(), - }) - .await - .unwrap(); - - let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local"); - assert_eq!( - result, - PluginInstallOutcome { - plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(), - plugin_version: "local".to_string(), - installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), - auth_policy: Some(MarketplacePluginAuthPolicy::OnUse), - } - ); - - let config = fs::read_to_string(tmp.path().join("config.toml")).unwrap(); - assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#)); - assert!(config.contains("enabled = true")); - } - - #[tokio::test] - async fn uninstall_plugin_removes_cache_and_config_entry() { - let tmp = tempfile::tempdir().unwrap(); - write_plugin( - &tmp.path().join("plugins/cache/debug"), - "sample-plugin/local", - "sample-plugin", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."sample-plugin@debug"] -enabled = true -"#, - ); - - let manager = PluginsManager::new(tmp.path().to_path_buf()); - manager - .uninstall_plugin("sample-plugin@debug".to_string()) - .await - .unwrap(); - manager - .uninstall_plugin("sample-plugin@debug".to_string()) - .await - .unwrap(); - - assert!( - !tmp.path() - .join("plugins/cache/debug/sample-plugin") - .exists() - ); - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); - } - - #[tokio::test] - async fn list_marketplaces_includes_enabled_state() { - let tmp = tempfile::tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - write_plugin( - &tmp.path().join("plugins/cache/debug"), - "enabled-plugin/local", - "enabled-plugin", - ); - write_plugin( - &tmp.path().join("plugins/cache/debug"), - "disabled-plugin/local", - "disabled-plugin", - ); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "debug", - "plugins": [ - { - "name": "enabled-plugin", - "source": { - "source": "local", - "path": "./enabled-plugin" - } - }, - { - "name": "disabled-plugin", - "source": { - "source": "local", - "path": "./disabled-plugin" - } - } - ] -}"#, - ) - .unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."enabled-plugin@debug"] -enabled = true - -[plugins."disabled-plugin@debug"] -enabled = false -"#, - ); - - let config = load_config(tmp.path(), &repo_root).await; - let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) - .unwrap(); - - let marketplace = marketplaces - .into_iter() - .find(|marketplace| { - marketplace.path - == AbsolutePathBuf::try_from( - tmp.path().join("repo/.agents/plugins/marketplace.json"), - ) - .unwrap() - }) - .expect("expected repo marketplace entry"); - - assert_eq!( - marketplace, - ConfiguredMarketplaceSummary { - name: "debug".to_string(), - path: AbsolutePathBuf::try_from( - tmp.path().join("repo/.agents/plugins/marketplace.json"), - ) - .unwrap(), - plugins: vec![ - ConfiguredMarketplacePluginSummary { - id: "enabled-plugin@debug".to_string(), - name: "enabled-plugin".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin")) - .unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - installed: true, - enabled: true, - }, - ConfiguredMarketplacePluginSummary { - id: "disabled-plugin@debug".to_string(), - name: "disabled-plugin".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from( - tmp.path().join("repo/disabled-plugin"), - ) - .unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - installed: true, - enabled: false, - }, - ], - } - ); - } - - #[tokio::test] - async fn list_marketplaces_includes_curated_repo_marketplace() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - let plugin_root = curated_root.join("plugins/linear"); - - fs::create_dir_all(curated_root.join(".git")).unwrap(); - fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); - fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); - fs::write( - curated_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "openai-curated", - "plugins": [ - { - "name": "linear", - "source": { - "source": "local", - "path": "./plugins/linear" - } - } - ] -}"#, - ) - .unwrap(); - fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"linear"}"#, - ) - .unwrap(); - - let config = load_config(tmp.path(), tmp.path()).await; - let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config(&config, &[]) - .unwrap(); - - let curated_marketplace = marketplaces - .into_iter() - .find(|marketplace| marketplace.name == "openai-curated") - .expect("curated marketplace should be listed"); - - assert_eq!( - curated_marketplace, - ConfiguredMarketplaceSummary { - name: "openai-curated".to_string(), - path: AbsolutePathBuf::try_from( - curated_root.join(".agents/plugins/marketplace.json") - ) - .unwrap(), - plugins: vec![ConfiguredMarketplacePluginSummary { - id: "linear@openai-curated".to_string(), - name: "linear".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")) - .unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - installed: false, - enabled: false, - }], - } - ); - } - - #[tokio::test] - async fn list_marketplaces_uses_first_duplicate_plugin_entry() { - let tmp = tempfile::tempdir().unwrap(); - let repo_a_root = tmp.path().join("repo-a"); - let repo_b_root = tmp.path().join("repo-b"); - fs::create_dir_all(repo_a_root.join(".git")).unwrap(); - fs::create_dir_all(repo_b_root.join(".git")).unwrap(); - fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap(); - fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap(); - fs::write( - repo_a_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "debug", - "plugins": [ - { - "name": "dup-plugin", - "source": { - "source": "local", - "path": "./from-a" - } - } - ] -}"#, - ) - .unwrap(); - fs::write( - repo_b_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "debug", - "plugins": [ - { - "name": "dup-plugin", - "source": { - "source": "local", - "path": "./from-b" - } - }, - { - "name": "b-only-plugin", - "source": { - "source": "local", - "path": "./from-b-only" - } - } - ] -}"#, - ) - .unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."dup-plugin@debug"] -enabled = true - -[plugins."b-only-plugin@debug"] -enabled = false -"#, - ); - - let config = load_config(tmp.path(), &repo_a_root).await; - let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config( - &config, - &[ - AbsolutePathBuf::try_from(repo_a_root).unwrap(), - AbsolutePathBuf::try_from(repo_b_root).unwrap(), - ], - ) - .unwrap(); - - let repo_a_marketplace = marketplaces - .iter() - .find(|marketplace| { - marketplace.path - == AbsolutePathBuf::try_from( - tmp.path().join("repo-a/.agents/plugins/marketplace.json"), - ) - .unwrap() - }) - .expect("repo-a marketplace should be listed"); - assert_eq!( - repo_a_marketplace.plugins, - vec![ConfiguredMarketplacePluginSummary { - id: "dup-plugin@debug".to_string(), - name: "dup-plugin".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - installed: false, - enabled: true, - }] - ); - - let repo_b_marketplace = marketplaces - .iter() - .find(|marketplace| { - marketplace.path - == AbsolutePathBuf::try_from( - tmp.path().join("repo-b/.agents/plugins/marketplace.json"), - ) - .unwrap() - }) - .expect("repo-b marketplace should be listed"); - assert_eq!( - repo_b_marketplace.plugins, - vec![ConfiguredMarketplacePluginSummary { - id: "b-only-plugin@debug".to_string(), - name: "b-only-plugin".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - installed: false, - enabled: false, - }] - ); - - let duplicate_plugin_count = marketplaces - .iter() - .flat_map(|marketplace| marketplace.plugins.iter()) - .filter(|plugin| plugin.name == "dup-plugin") - .count(); - assert_eq!(duplicate_plugin_count, 1); - } - - #[tokio::test] - async fn list_marketplaces_marks_configured_plugin_uninstalled_when_cache_is_missing() { - let tmp = tempfile::tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "debug", - "plugins": [ - { - "name": "sample-plugin", - "source": { - "source": "local", - "path": "./sample-plugin" - } - } - ] -}"#, - ) - .unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."sample-plugin@debug"] -enabled = true -"#, - ); - - let config = load_config(tmp.path(), &repo_root).await; - let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) - .unwrap(); - - let marketplace = marketplaces - .into_iter() - .find(|marketplace| { - marketplace.path - == AbsolutePathBuf::try_from( - tmp.path().join("repo/.agents/plugins/marketplace.json"), - ) - .unwrap() - }) - .expect("expected repo marketplace entry"); - - assert_eq!( - marketplace, - ConfiguredMarketplaceSummary { - name: "debug".to_string(), - path: AbsolutePathBuf::try_from( - tmp.path().join("repo/.agents/plugins/marketplace.json"), - ) - .unwrap(), - plugins: vec![ConfiguredMarketplacePluginSummary { - id: "sample-plugin@debug".to_string(), - name: "sample-plugin".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")) - .unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - installed: false, - enabled: true, - }], - } - ); - } - - #[tokio::test] - async fn sync_plugins_from_remote_reconciles_cache_and_config() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "calendar/local", - "calendar", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false - -[plugins."calendar@openai-curated"] -enabled = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, - {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: vec!["gmail@openai-curated".to_string()], - enabled_plugin_ids: vec!["linear@openai-curated".to_string()], - disabled_plugin_ids: vec!["gmail@openai-curated".to_string()], - uninstalled_plugin_ids: vec!["calendar@openai-curated".to_string()], - } - ); - - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/gmail/local") - .is_dir() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/calendar") - .exists() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(config.contains("enabled = true")); - assert!(config.contains("enabled = false")); - assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#)); - - let synced_config = load_config(tmp.path(), tmp.path()).await; - let curated_marketplace = manager - .list_marketplaces_for_config(&synced_config, &[]) - .unwrap() - .into_iter() - .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) - .unwrap(); - assert_eq!( - curated_marketplace - .plugins - .into_iter() - .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) - .collect::>(), - vec![ - ("linear@openai-curated".to_string(), true, true), - ("gmail@openai-curated".to_string(), true, false), - ("calendar@openai-curated".to_string(), false, false), - ] - ); - } - - #[tokio::test] - async fn sync_plugins_from_remote_ignores_unknown_remote_plugins() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear"]); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"plugin-one","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: Vec::new(), - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: vec!["linear@openai-curated".to_string()], - } - ); - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(!config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/linear") - .exists() - ); - } - - #[tokio::test] - async fn sync_plugins_from_remote_keeps_existing_plugins_when_install_fails() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]); - fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap(); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let err = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - ) - .await - .unwrap_err(); - - assert!(matches!( - err, - PluginRemoteSyncError::Store(PluginStoreError::Invalid(ref message)) - if message.contains("plugin source path is not a directory") - )); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/gmail") - .exists() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(config.contains("enabled = false")); - } - - #[tokio::test] - async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - fs::create_dir_all(curated_root.join(".git")).unwrap(); - fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); - fs::write( - curated_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "openai-curated", - "plugins": [ - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail-first" - } - }, - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail-second" - } - } - ] -}"#, - ) - .unwrap(); - write_plugin(&curated_root, "plugins/gmail-first", "gmail"); - write_plugin(&curated_root, "plugins/gmail-second", "gmail"); - fs::write(curated_root.join("plugins/gmail-first/marker.txt"), "first").unwrap(); - fs::write( - curated_root.join("plugins/gmail-second/marker.txt"), - "second", - ) - .unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: vec!["gmail@openai-curated".to_string()], - enabled_plugin_ids: vec!["gmail@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: Vec::new(), - } - ); - assert_eq!( - fs::read_to_string( - tmp.path() - .join("plugins/cache/openai-curated/gmail/local/marker.txt") - ) - .unwrap(), - "first" - ); - } - - #[test] - fn load_plugins_ignores_project_config_files() { - let codex_home = TempDir::new().unwrap(); - let project_root = codex_home.path().join("project"); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); - - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ); - write_file( - &project_root.join(".codex/config.toml"), - &plugin_config_toml(true, true), - ); - - let stack = ConfigLayerStack::new( - vec![ConfigLayerEntry::new( - ConfigLayerSource::Project { - dot_codex_folder: AbsolutePathBuf::try_from(project_root.join(".codex")) - .unwrap(), - }, - toml::from_str(&plugin_config_toml(true, true)) - .expect("project config should parse"), - )], - ConfigRequirements::default(), - ConfigRequirementsToml::default(), - ) - .expect("config layer stack should build"); - - let outcome = PluginsManager::new(codex_home.path().to_path_buf()).plugins_for_layer_stack( - &project_root, - &stack, - false, - ); - - assert_eq!(outcome, PluginLoadOutcome::default()); - } -} +#[path = "manager_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs new file mode 100644 index 0000000000..2f543aa679 --- /dev/null +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -0,0 +1,1626 @@ +use super::*; +use crate::auth::CodexAuth; +use crate::config::CONFIG_TOML_FILE; +use crate::config::ConfigBuilder; +use crate::config::types::McpServerTransportConfig; +use crate::config_loader::ConfigLayerEntry; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigRequirements; +use crate::config_loader::ConfigRequirementsToml; +use codex_app_server_protocol::ConfigLayerSource; +use pretty_assertions::assert_eq; +use std::fs; +use tempfile::TempDir; +use toml::Value; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + fs::write(path, contents).unwrap(); +} + +fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { + let plugin_root = root.join(dir_name); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::create_dir_all(plugin_root.join("skills")).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{manifest_name}"}}"#), + ) + .unwrap(); + fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap(); + fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); +} + +fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { + fs::create_dir_all(root.join(".agents/plugins")).unwrap(); + let plugins = plugin_names + .iter() + .map(|plugin_name| { + format!( + r#"{{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }}"# + ) + }) + .collect::>() + .join(",\n"); + fs::write( + root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", + "plugins": [ +{plugins} + ] +}}"# + ), + ) + .unwrap(); + for plugin_name in plugin_names { + write_plugin(root, &format!("plugins/{plugin_name}"), plugin_name); + } +} + +fn write_curated_plugin_sha(codex_home: &Path, sha: &str) { + write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n")); +} + +fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { + let mut root = toml::map::Map::new(); + + let mut features = toml::map::Map::new(); + features.insert( + "plugins".to_string(), + Value::Boolean(plugins_feature_enabled), + ); + root.insert("features".to_string(), Value::Table(features)); + + let mut plugin = toml::map::Map::new(); + plugin.insert("enabled".to_string(), Value::Boolean(enabled)); + + let mut plugins = toml::map::Map::new(); + plugins.insert("sample@test".to_string(), Value::Table(plugin)); + root.insert("plugins".to_string(), Value::Table(plugins)); + + toml::to_string(&Value::Table(root)).expect("plugin test config should serialize") +} + +fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> PluginLoadOutcome { + write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml); + let stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { + file: AbsolutePathBuf::try_from(codex_home.join(CONFIG_TOML_FILE)).unwrap(), + }, + toml::from_str(config_toml).expect("plugin test config should parse"), + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack should build"); + PluginsManager::new(codex_home.to_path_buf()).plugins_for_layer_stack(codex_home, &stack, false) +} + +async fn load_config(codex_home: &Path, cwd: &Path) -> crate::config::Config { + ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .fallback_cwd(Some(cwd.to_path_buf())) + .build() + .await + .expect("config should load") +} + +#[test] +fn load_plugins_loads_default_skills_and_mcp_servers() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample", + "description": "Plugin that includes the sample MCP server and Skills" +}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp", + "oauth": { + "clientId": "client-id", + "callbackPort": 3118 + } + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "example": { + "id": "connector_example" + } + } +}"#, + ); + + let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); + + assert_eq!( + outcome.plugins, + vec![LoadedPlugin { + config_name: "sample@test".to_string(), + manifest_name: Some("sample".to_string()), + manifest_description: Some( + "Plugin that includes the sample MCP server and Skills".to_string(), + ), + root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), + enabled: true, + skill_roots: vec![plugin_root.join("skills")], + mcp_servers: HashMap::from([( + "sample".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://sample.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]), + apps: vec![AppConnectorId("connector_example".to_string())], + error: None, + }] + ); + assert_eq!( + outcome.capability_summaries(), + &[PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: Some("Plugin that includes the sample MCP server and Skills".to_string(),), + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: vec![AppConnectorId("connector_example".to_string())], + }] + ); + assert_eq!( + outcome.effective_skill_roots(), + vec![plugin_root.join("skills")] + ); + assert_eq!(outcome.effective_mcp_servers().len(), 1); + assert_eq!( + outcome.effective_apps(), + vec![AppConnectorId("connector_example".to_string())] + ); +} + +#[test] +fn load_plugins_uses_manifest_configured_component_paths() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample", + "skills": "./custom-skills/", + "mcpServers": "./config/custom.mcp.json", + "apps": "./config/custom.app.json" +}"#, + ); + write_file( + &plugin_root.join("skills/default-skill/SKILL.md"), + "---\nname: default-skill\ndescription: default skill\n---\n", + ); + write_file( + &plugin_root.join("custom-skills/custom-skill/SKILL.md"), + "---\nname: custom-skill\ndescription: custom skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "default": { + "type": "http", + "url": "https://default.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.mcp.json"), + r#"{ + "mcpServers": { + "custom": { + "type": "http", + "url": "https://custom.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "default": { + "id": "connector_default" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.app.json"), + r#"{ + "apps": { + "custom": { + "id": "connector_custom" + } + } +}"#, + ); + + let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); + + assert_eq!( + outcome.plugins[0].skill_roots, + vec![ + plugin_root.join("custom-skills"), + plugin_root.join("skills") + ] + ); + assert_eq!( + outcome.plugins[0].mcp_servers, + HashMap::from([( + "custom".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://custom.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]) + ); + assert_eq!( + outcome.plugins[0].apps, + vec![AppConnectorId("connector_custom".to_string())] + ); +} + +#[test] +fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample", + "skills": "custom-skills", + "mcpServers": "config/custom.mcp.json", + "apps": "config/custom.app.json" +}"#, + ); + write_file( + &plugin_root.join("skills/default-skill/SKILL.md"), + "---\nname: default-skill\ndescription: default skill\n---\n", + ); + write_file( + &plugin_root.join("custom-skills/custom-skill/SKILL.md"), + "---\nname: custom-skill\ndescription: custom skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "default": { + "type": "http", + "url": "https://default.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.mcp.json"), + r#"{ + "mcpServers": { + "custom": { + "type": "http", + "url": "https://custom.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "default": { + "id": "connector_default" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.app.json"), + r#"{ + "apps": { + "custom": { + "id": "connector_custom" + } + } +}"#, + ); + + let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); + + assert_eq!( + outcome.plugins[0].skill_roots, + vec![plugin_root.join("skills")] + ); + assert_eq!( + outcome.plugins[0].mcp_servers, + HashMap::from([( + "default".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://default.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]) + ); + assert_eq!( + outcome.plugins[0].apps, + vec![AppConnectorId("connector_default".to_string())] + ); +} + +#[test] +fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ); + + let outcome = load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path()); + + assert_eq!( + outcome.plugins, + vec![LoadedPlugin { + config_name: "sample@test".to_string(), + manifest_name: None, + manifest_description: None, + root: AbsolutePathBuf::try_from(plugin_root).unwrap(), + enabled: false, + skill_roots: Vec::new(), + mcp_servers: HashMap::new(), + apps: Vec::new(), + error: None, + }] + ); + assert!(outcome.effective_skill_roots().is_empty()); + assert!(outcome.effective_mcp_servers().is_empty()); +} + +#[test] +fn effective_apps_dedupes_connector_ids_across_plugins() { + let codex_home = TempDir::new().unwrap(); + let plugin_a_root = codex_home + .path() + .join("plugins/cache") + .join("test/plugin-a/local"); + let plugin_b_root = codex_home + .path() + .join("plugins/cache") + .join("test/plugin-b/local"); + + write_file( + &plugin_a_root.join(".codex-plugin/plugin.json"), + r#"{"name":"plugin-a"}"#, + ); + write_file( + &plugin_a_root.join(".app.json"), + r#"{ + "apps": { + "example": { + "id": "connector_example" + } + } +}"#, + ); + write_file( + &plugin_b_root.join(".codex-plugin/plugin.json"), + r#"{"name":"plugin-b"}"#, + ); + write_file( + &plugin_b_root.join(".app.json"), + r#"{ + "apps": { + "chat": { + "id": "connector_example" + }, + "gmail": { + "id": "connector_gmail" + } + } +}"#, + ); + + let mut root = toml::map::Map::new(); + let mut features = toml::map::Map::new(); + features.insert("plugins".to_string(), Value::Boolean(true)); + root.insert("features".to_string(), Value::Table(features)); + + let mut plugins = toml::map::Map::new(); + + let mut plugin_a = toml::map::Map::new(); + plugin_a.insert("enabled".to_string(), Value::Boolean(true)); + plugins.insert("plugin-a@test".to_string(), Value::Table(plugin_a)); + + let mut plugin_b = toml::map::Map::new(); + plugin_b.insert("enabled".to_string(), Value::Boolean(true)); + plugins.insert("plugin-b@test".to_string(), Value::Table(plugin_b)); + + root.insert("plugins".to_string(), Value::Table(plugins)); + let config_toml = + toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"); + + let outcome = load_plugins_from_config(&config_toml, codex_home.path()); + + assert_eq!( + outcome.effective_apps(), + vec![ + AppConnectorId("connector_example".to_string()), + AppConnectorId("connector_gmail".to_string()), + ] + ); +} + +#[test] +fn capability_index_filters_inactive_and_zero_capability_plugins() { + let codex_home = TempDir::new().unwrap(); + let connector = |id: &str| AppConnectorId(id.to_string()); + let http_server = |url: &str| McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: url.to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }; + let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin { + config_name: config_name.to_string(), + manifest_name: Some(manifest_name.to_string()), + manifest_description: None, + root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), + enabled: true, + skill_roots: Vec::new(), + mcp_servers: HashMap::new(), + apps: Vec::new(), + error: None, + }; + let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary { + config_name: config_name.to_string(), + display_name: display_name.to_string(), + description: None, + ..PluginCapabilitySummary::default() + }; + let outcome = PluginLoadOutcome::from_plugins(vec![ + LoadedPlugin { + skill_roots: vec![codex_home.path().join("skills-plugin/skills")], + ..plugin("skills@test", "skills-plugin", "skills-plugin") + }, + LoadedPlugin { + mcp_servers: HashMap::from([("alpha".to_string(), http_server("https://alpha"))]), + apps: vec![connector("connector_example")], + ..plugin("alpha@test", "alpha-plugin", "alpha-plugin") + }, + LoadedPlugin { + mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]), + apps: vec![connector("connector_example"), connector("connector_gmail")], + ..plugin("beta@test", "beta-plugin", "beta-plugin") + }, + plugin("empty@test", "empty-plugin", "empty-plugin"), + LoadedPlugin { + enabled: false, + skill_roots: vec![codex_home.path().join("disabled-plugin/skills")], + apps: vec![connector("connector_hidden")], + ..plugin("disabled@test", "disabled-plugin", "disabled-plugin") + }, + LoadedPlugin { + apps: vec![connector("connector_broken")], + error: Some("failed to load".to_string()), + ..plugin("broken@test", "broken-plugin", "broken-plugin") + }, + ]); + + assert_eq!( + outcome.capability_summaries(), + &[ + PluginCapabilitySummary { + has_skills: true, + ..summary("skills@test", "skills-plugin") + }, + PluginCapabilitySummary { + mcp_server_names: vec!["alpha".to_string()], + app_connector_ids: vec![connector("connector_example")], + ..summary("alpha@test", "alpha-plugin") + }, + PluginCapabilitySummary { + mcp_server_names: vec!["beta".to_string()], + app_connector_ids: vec![ + connector("connector_example"), + connector("connector_gmail"), + ], + ..summary("beta@test", "beta-plugin") + }, + ] + ); +} + +#[test] +fn plugin_namespace_for_skill_path_uses_manifest_name() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home.path().join("plugins/sample"); + let skill_path = plugin_root.join("skills/search/SKILL.md"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file(&skill_path, "---\ndescription: search\n---\n"); + + assert_eq!( + plugin_namespace_for_skill_path(&skill_path), + Some("sample".to_string()) + ); +} + +#[test] +fn load_plugins_returns_empty_when_feature_disabled() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + + let outcome = load_plugins_from_config(&plugin_config_toml(true, false), codex_home.path()); + + assert_eq!(outcome, PluginLoadOutcome::default()); +} + +#[test] +fn load_plugins_rejects_invalid_plugin_keys() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + + let mut root = toml::map::Map::new(); + let mut features = toml::map::Map::new(); + features.insert("plugins".to_string(), Value::Boolean(true)); + root.insert("features".to_string(), Value::Table(features)); + + let mut plugin = toml::map::Map::new(); + plugin.insert("enabled".to_string(), Value::Boolean(true)); + + let mut plugins = toml::map::Map::new(); + plugins.insert("sample".to_string(), Value::Table(plugin)); + root.insert("plugins".to_string(), Value::Table(plugins)); + + let outcome = load_plugins_from_config( + &toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"), + codex_home.path(), + ); + + assert_eq!(outcome.plugins.len(), 1); + assert_eq!( + outcome.plugins[0].error.as_deref(), + Some("invalid plugin key `sample`; expected @") + ); + assert!(outcome.effective_skill_roots().is_empty()); + assert!(outcome.effective_mcp_servers().is_empty()); +} + +#[tokio::test] +async fn install_plugin_updates_config_with_relative_path_and_plugin_key() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin(&repo_root, "sample-plugin", "sample-plugin"); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + }, + "authPolicy": "ON_USE" + } + ] +}"#, + ) + .unwrap(); + + let result = PluginsManager::new(tmp.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "sample-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }) + .await + .unwrap(); + + let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local"); + assert_eq!( + result, + PluginInstallOutcome { + plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(), + plugin_version: "local".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), + auth_policy: MarketplacePluginAuthPolicy::OnUse, + } + ); + + let config = fs::read_to_string(tmp.path().join("config.toml")).unwrap(); + assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#)); + assert!(config.contains("enabled = true")); +} + +#[tokio::test] +async fn uninstall_plugin_removes_cache_and_config_entry() { + let tmp = tempfile::tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + let manager = PluginsManager::new(tmp.path().to_path_buf()); + manager + .uninstall_plugin("sample-plugin@debug".to_string()) + .await + .unwrap(); + manager + .uninstall_plugin("sample-plugin@debug".to_string()) + .await + .unwrap(); + + assert!( + !tmp.path() + .join("plugins/cache/debug/sample-plugin") + .exists() + ); + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); +} + +#[tokio::test] +async fn list_marketplaces_includes_enabled_state() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "enabled-plugin/local", + "enabled-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "disabled-plugin/local", + "disabled-plugin", + ); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "enabled-plugin", + "source": { + "source": "local", + "path": "./enabled-plugin" + } + }, + { + "name": "disabled-plugin", + "source": { + "source": "local", + "path": "./disabled-plugin" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."enabled-plugin@debug"] +enabled = true + +[plugins."disabled-plugin@debug"] +enabled = false +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .unwrap(); + + let marketplace = marketplaces + .into_iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("expected repo marketplace entry"); + + assert_eq!( + marketplace, + ConfiguredMarketplaceSummary { + name: "debug".to_string(), + path: AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap(), + plugins: vec![ + ConfiguredMarketplacePluginSummary { + id: "enabled-plugin@debug".to_string(), + name: "enabled-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin")) + .unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + installed: true, + enabled: true, + }, + ConfiguredMarketplacePluginSummary { + id: "disabled-plugin@debug".to_string(), + name: "disabled-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),) + .unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + installed: true, + enabled: false, + }, + ], + } + ); +} + +#[tokio::test] +async fn list_marketplaces_includes_curated_repo_marketplace() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + let plugin_root = curated_root.join("plugins/linear"); + + fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + curated_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "linear", + "source": { + "source": "local", + "path": "./plugins/linear" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"linear"}"#, + ) + .unwrap(); + + let config = load_config(tmp.path(), tmp.path()).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[]) + .unwrap(); + + let curated_marketplace = marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "openai-curated") + .expect("curated marketplace should be listed"); + + assert_eq!( + curated_marketplace, + ConfiguredMarketplaceSummary { + name: "openai-curated".to_string(), + path: AbsolutePathBuf::try_from(curated_root.join(".agents/plugins/marketplace.json")) + .unwrap(), + plugins: vec![ConfiguredMarketplacePluginSummary { + id: "linear@openai-curated".to_string(), + name: "linear".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + installed: false, + enabled: false, + }], + } + ); +} + +#[tokio::test] +async fn list_marketplaces_uses_first_duplicate_plugin_entry() { + let tmp = tempfile::tempdir().unwrap(); + let repo_a_root = tmp.path().join("repo-a"); + let repo_b_root = tmp.path().join("repo-b"); + fs::create_dir_all(repo_a_root.join(".git")).unwrap(); + fs::create_dir_all(repo_b_root.join(".git")).unwrap(); + fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_a_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "dup-plugin", + "source": { + "source": "local", + "path": "./from-a" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + repo_b_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "dup-plugin", + "source": { + "source": "local", + "path": "./from-b" + } + }, + { + "name": "b-only-plugin", + "source": { + "source": "local", + "path": "./from-b-only" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."dup-plugin@debug"] +enabled = true + +[plugins."b-only-plugin@debug"] +enabled = false +"#, + ); + + let config = load_config(tmp.path(), &repo_a_root).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config( + &config, + &[ + AbsolutePathBuf::try_from(repo_a_root).unwrap(), + AbsolutePathBuf::try_from(repo_b_root).unwrap(), + ], + ) + .unwrap(); + + let repo_a_marketplace = marketplaces + .iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo-a/.agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("repo-a marketplace should be listed"); + assert_eq!( + repo_a_marketplace.plugins, + vec![ConfiguredMarketplacePluginSummary { + id: "dup-plugin@debug".to_string(), + name: "dup-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + installed: false, + enabled: true, + }] + ); + + let repo_b_marketplace = marketplaces + .iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo-b/.agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("repo-b marketplace should be listed"); + assert_eq!( + repo_b_marketplace.plugins, + vec![ConfiguredMarketplacePluginSummary { + id: "b-only-plugin@debug".to_string(), + name: "b-only-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + installed: false, + enabled: false, + }] + ); + + let duplicate_plugin_count = marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .filter(|plugin| plugin.name == "dup-plugin") + .count(); + assert_eq!(duplicate_plugin_count, 1); +} + +#[tokio::test] +async fn list_marketplaces_marks_configured_plugin_uninstalled_when_cache_is_missing() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .unwrap(); + + let marketplace = marketplaces + .into_iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("expected repo marketplace entry"); + + assert_eq!( + marketplace, + ConfiguredMarketplaceSummary { + name: "debug".to_string(), + path: AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap(), + plugins: vec![ConfiguredMarketplacePluginSummary { + id: "sample-plugin@debug".to_string(), + name: "sample-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + installed: false, + enabled: true, + }], + } + ); +} + +#[tokio::test] +async fn sync_plugins_from_remote_reconciles_cache_and_config() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "linear/local", + "linear", + ); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "calendar/local", + "calendar", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false + +[plugins."calendar@openai-curated"] +enabled = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, + {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let result = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginSyncResult { + installed_plugin_ids: vec!["gmail@openai-curated".to_string()], + enabled_plugin_ids: vec!["linear@openai-curated".to_string()], + disabled_plugin_ids: vec!["gmail@openai-curated".to_string()], + uninstalled_plugin_ids: vec!["calendar@openai-curated".to_string()], + } + ); + + assert!( + tmp.path() + .join("plugins/cache/openai-curated/linear/local") + .is_dir() + ); + assert!( + tmp.path() + .join(format!( + "plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}" + )) + .is_dir() + ); + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/calendar") + .exists() + ); + + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); + assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#)); + assert!(config.contains("enabled = true")); + assert!(config.contains("enabled = false")); + assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#)); + + let synced_config = load_config(tmp.path(), tmp.path()).await; + let curated_marketplace = manager + .list_marketplaces_for_config(&synced_config, &[]) + .unwrap() + .into_iter() + .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + .unwrap(); + assert_eq!( + curated_marketplace + .plugins + .into_iter() + .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) + .collect::>(), + vec![ + ("linear@openai-curated".to_string(), true, true), + ("gmail@openai-curated".to_string(), true, false), + ("calendar@openai-curated".to_string(), false, false), + ] + ); +} + +#[tokio::test] +async fn sync_plugins_from_remote_ignores_unknown_remote_plugins() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"plugin-one","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let result = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginSyncResult { + installed_plugin_ids: Vec::new(), + enabled_plugin_ids: Vec::new(), + disabled_plugin_ids: Vec::new(), + uninstalled_plugin_ids: vec!["linear@openai-curated".to_string()], + } + ); + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains(r#"[plugins."linear@openai-curated"]"#)); + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/linear") + .exists() + ); +} + +#[tokio::test] +async fn sync_plugins_from_remote_keeps_existing_plugins_when_install_fails() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "linear/local", + "linear", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let err = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await + .unwrap_err(); + + assert!(matches!( + err, + PluginRemoteSyncError::Store(PluginStoreError::Invalid(ref message)) + if message.contains("plugin source path is not a directory") + )); + assert!( + tmp.path() + .join("plugins/cache/openai-curated/linear/local") + .is_dir() + ); + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/gmail") + .exists() + ); + + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); + assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); + assert!(config.contains("enabled = false")); +} + +#[tokio::test] +async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); + fs::write( + curated_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail-first" + } + }, + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail-second" + } + } + ] +}"#, + ) + .unwrap(); + write_plugin(&curated_root, "plugins/gmail-first", "gmail"); + write_plugin(&curated_root, "plugins/gmail-second", "gmail"); + fs::write(curated_root.join("plugins/gmail-first/marker.txt"), "first").unwrap(); + fs::write( + curated_root.join("plugins/gmail-second/marker.txt"), + "second", + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let result = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginSyncResult { + installed_plugin_ids: vec!["gmail@openai-curated".to_string()], + enabled_plugin_ids: vec!["gmail@openai-curated".to_string()], + disabled_plugin_ids: Vec::new(), + uninstalled_plugin_ids: Vec::new(), + } + ); + assert_eq!( + fs::read_to_string(tmp.path().join(format!( + "plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}/marker.txt" + ))) + .unwrap(), + "first" + ); +} + +#[test] +fn refresh_curated_plugin_cache_replaces_existing_local_version_with_sha() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + let plugin_id = PluginId::new( + "slack".to_string(), + OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "slack/local", + "slack", + ); + + assert!( + refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should succeed") + ); + + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/slack/local") + .exists() + ); + assert!( + tmp.path() + .join(format!( + "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_SHA}" + )) + .is_dir() + ); +} + +#[test] +fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_current_sha() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + let plugin_id = PluginId::new( + "slack".to_string(), + OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + + assert!( + refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should recreate missing configured plugin") + ); + + assert!( + tmp.path() + .join(format!( + "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_SHA}" + )) + .is_dir() + ); +} + +#[test] +fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + let plugin_id = PluginId::new( + "slack".to_string(), + OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + &format!("slack/{TEST_CURATED_PLUGIN_SHA}"), + "slack", + ); + + assert!( + !refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should be a no-op when configured plugins are current") + ); +} + +#[test] +fn load_plugins_ignores_project_config_files() { + let codex_home = TempDir::new().unwrap(); + let project_root = codex_home.path().join("project"); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &project_root.join(".codex/config.toml"), + &plugin_config_toml(true, true), + ); + + let stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: AbsolutePathBuf::try_from(project_root.join(".codex")).unwrap(), + }, + toml::from_str(&plugin_config_toml(true, true)).expect("project config should parse"), + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack should build"); + + let outcome = PluginsManager::new(codex_home.path().to_path_buf()).plugins_for_layer_stack( + &project_root, + &stack, + false, + ); + + assert_eq!(outcome, PluginLoadOutcome::default()); +} diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index fb0abd205f..622ed9dd17 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -21,7 +21,7 @@ const MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json"; pub struct ResolvedMarketplacePlugin { pub plugin_id: PluginId, pub source_path: AbsolutePathBuf, - pub auth_policy: Option, + pub auth_policy: MarketplacePluginAuthPolicy, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -35,8 +35,8 @@ pub struct MarketplaceSummary { pub struct MarketplacePluginSummary { pub name: String, pub source: MarketplacePluginSourceSummary, - pub install_policy: Option, - pub auth_policy: Option, + pub install_policy: MarketplacePluginInstallPolicy, + pub auth_policy: MarketplacePluginAuthPolicy, pub interface: Option, } @@ -45,18 +45,20 @@ pub enum MarketplacePluginSourceSummary { Local { path: AbsolutePathBuf }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] pub enum MarketplacePluginInstallPolicy { #[serde(rename = "NOT_AVAILABLE")] NotAvailable, + #[default] #[serde(rename = "AVAILABLE")] Available, #[serde(rename = "INSTALLED_BY_DEFAULT")] InstalledByDefault, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] pub enum MarketplacePluginAuthPolicy { + #[default] #[serde(rename = "ON_INSTALL")] OnInstall, #[serde(rename = "ON_USE")] @@ -148,7 +150,7 @@ pub fn resolve_marketplace_plugin( auth_policy, .. } = plugin; - if install_policy == Some(MarketplacePluginInstallPolicy::NotAvailable) { + if install_policy == MarketplacePluginInstallPolicy::NotAvailable { return Err(MarketplaceError::PluginNotAvailable { plugin_name: name, marketplace_name, @@ -243,6 +245,15 @@ fn discover_marketplace_paths_from_roots( } for root in additional_roots { + // Curated marketplaces can now come from an HTTP-downloaded directory that is not a git + // checkout, so check the root directly before falling back to repo-root discovery. + if let Ok(path) = root.join(MARKETPLACE_RELATIVE_PATH) + && path.as_path().is_file() + && !paths.contains(&path) + { + paths.push(path); + continue; + } if let Some(repo_root) = get_git_repo_root(root.as_path()) && let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root) && let Ok(path) = repo_root.join(MARKETPLACE_RELATIVE_PATH) @@ -365,9 +376,9 @@ struct MarketplacePlugin { name: String, source: MarketplacePluginSource, #[serde(default)] - install_policy: Option, + install_policy: MarketplacePluginInstallPolicy, #[serde(default)] - auth_policy: Option, + auth_policy: MarketplacePluginAuthPolicy, #[serde(default)] category: Option, } @@ -379,576 +390,5 @@ enum MarketplacePluginSource { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::tempdir; - - #[test] - fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() { - let tmp = tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - fs::create_dir_all(repo_root.join("nested")).unwrap(); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "codex-curated", - "plugins": [ - { - "name": "local-plugin", - "source": { - "source": "local", - "path": "./plugin-1" - } - } - ] -}"#, - ) - .unwrap(); - - let resolved = resolve_marketplace_plugin( - &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), - "local-plugin", - ) - .unwrap(); - - assert_eq!( - resolved, - ResolvedMarketplacePlugin { - plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string()) - .unwrap(), - source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(), - auth_policy: None, - } - ); - } - - #[test] - fn resolve_marketplace_plugin_reports_missing_plugin() { - let tmp = tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{"name":"codex-curated","plugins":[]}"#, - ) - .unwrap(); - - let err = resolve_marketplace_plugin( - &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), - "missing", - ) - .unwrap_err(); - - assert_eq!( - err.to_string(), - "plugin `missing` was not found in marketplace `codex-curated`" - ); - } - - #[test] - fn list_marketplaces_returns_home_and_repo_marketplaces() { - let tmp = tempdir().unwrap(); - let home_root = tmp.path().join("home"); - let repo_root = tmp.path().join("repo"); - - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(home_root.join(".agents/plugins")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - fs::write( - home_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "codex-curated", - "plugins": [ - { - "name": "shared-plugin", - "source": { - "source": "local", - "path": "./home-shared" - } - }, - { - "name": "home-only", - "source": { - "source": "local", - "path": "./home-only" - } - } - ] -}"#, - ) - .unwrap(); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "codex-curated", - "plugins": [ - { - "name": "shared-plugin", - "source": { - "source": "local", - "path": "./repo-shared" - } - }, - { - "name": "repo-only", - "source": { - "source": "local", - "path": "./repo-only" - } - } - ] -}"#, - ) - .unwrap(); - - let marketplaces = list_marketplaces_with_home( - &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], - Some(&home_root), - ) - .unwrap(); - - assert_eq!( - marketplaces, - vec![ - MarketplaceSummary { - name: "codex-curated".to_string(), - path: AbsolutePathBuf::try_from( - home_root.join(".agents/plugins/marketplace.json"), - ) - .unwrap(), - plugins: vec![ - MarketplacePluginSummary { - name: "shared-plugin".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(home_root.join("home-shared")) - .unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - }, - MarketplacePluginSummary { - name: "home-only".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(home_root.join("home-only")) - .unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - }, - ], - }, - MarketplaceSummary { - name: "codex-curated".to_string(), - path: AbsolutePathBuf::try_from( - repo_root.join(".agents/plugins/marketplace.json"), - ) - .unwrap(), - plugins: vec![ - MarketplacePluginSummary { - name: "shared-plugin".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(repo_root.join("repo-shared")) - .unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - }, - MarketplacePluginSummary { - name: "repo-only".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(repo_root.join("repo-only")) - .unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - }, - ], - }, - ] - ); - } - - #[test] - fn list_marketplaces_keeps_distinct_entries_for_same_name() { - let tmp = tempdir().unwrap(); - let home_root = tmp.path().join("home"); - let repo_root = tmp.path().join("repo"); - let home_marketplace = home_root.join(".agents/plugins/marketplace.json"); - let repo_marketplace = repo_root.join(".agents/plugins/marketplace.json"); - - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(home_root.join(".agents/plugins")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - - fs::write( - home_marketplace.clone(), - r#"{ - "name": "codex-curated", - "plugins": [ - { - "name": "local-plugin", - "source": { - "source": "local", - "path": "./home-plugin" - } - } - ] -}"#, - ) - .unwrap(); - fs::write( - repo_marketplace.clone(), - r#"{ - "name": "codex-curated", - "plugins": [ - { - "name": "local-plugin", - "source": { - "source": "local", - "path": "./repo-plugin" - } - } - ] -}"#, - ) - .unwrap(); - - let marketplaces = list_marketplaces_with_home( - &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], - Some(&home_root), - ) - .unwrap(); - - assert_eq!( - marketplaces, - vec![ - MarketplaceSummary { - name: "codex-curated".to_string(), - path: AbsolutePathBuf::try_from(home_marketplace).unwrap(), - plugins: vec![MarketplacePluginSummary { - name: "local-plugin".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - }], - }, - MarketplaceSummary { - name: "codex-curated".to_string(), - path: AbsolutePathBuf::try_from(repo_marketplace.clone()).unwrap(), - plugins: vec![MarketplacePluginSummary { - name: "local-plugin".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - }], - }, - ] - ); - - let resolved = resolve_marketplace_plugin( - &AbsolutePathBuf::try_from(repo_marketplace).unwrap(), - "local-plugin", - ) - .unwrap(); - - assert_eq!( - resolved.source_path, - AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap() - ); - } - - #[test] - fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { - let tmp = tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - let nested_root = repo_root.join("nested/project"); - - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - fs::create_dir_all(&nested_root).unwrap(); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "codex-curated", - "plugins": [ - { - "name": "local-plugin", - "source": { - "source": "local", - "path": "./plugin" - } - } - ] -}"#, - ) - .unwrap(); - - let marketplaces = list_marketplaces_with_home( - &[ - AbsolutePathBuf::try_from(repo_root.clone()).unwrap(), - AbsolutePathBuf::try_from(nested_root).unwrap(), - ], - None, - ) - .unwrap(); - - assert_eq!( - marketplaces, - vec![MarketplaceSummary { - name: "codex-curated".to_string(), - path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")) - .unwrap(), - plugins: vec![MarketplacePluginSummary { - name: "local-plugin".to_string(), - source: MarketplacePluginSourceSummary::Local { - path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(), - }, - install_policy: None, - auth_policy: None, - interface: None, - }], - }] - ); - } - - #[test] - fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() { - let tmp = tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - let plugin_root = repo_root.join("plugins/demo-plugin"); - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "codex-curated", - "plugins": [ - { - "name": "demo-plugin", - "source": { - "source": "local", - "path": "./plugins/demo-plugin" - }, - "installPolicy": "AVAILABLE", - "authPolicy": "ON_INSTALL", - "category": "Design" - } - ] -}"#, - ) - .unwrap(); - fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{ - "name": "demo-plugin", - "interface": { - "displayName": "Demo", - "category": "Productivity", - "capabilities": ["Interactive", "Write"], - "composerIcon": "./assets/icon.png", - "logo": "./assets/logo.png", - "screenshots": ["./assets/shot1.png"] - } -}"#, - ) - .unwrap(); - - let marketplaces = - list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) - .unwrap(); - - assert_eq!( - marketplaces[0].plugins[0].install_policy, - Some(MarketplacePluginInstallPolicy::Available) - ); - assert_eq!( - marketplaces[0].plugins[0].auth_policy, - Some(MarketplacePluginAuthPolicy::OnInstall) - ); - assert_eq!( - marketplaces[0].plugins[0].interface, - Some(PluginManifestInterfaceSummary { - display_name: Some("Demo".to_string()), - short_description: None, - long_description: None, - developer_name: None, - category: Some("Design".to_string()), - capabilities: vec!["Interactive".to_string(), "Write".to_string()], - website_url: None, - privacy_policy_url: None, - terms_of_service_url: None, - default_prompt: None, - brand_color: None, - composer_icon: Some( - AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(), - ), - logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()), - screenshots: vec![ - AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(), - ], - }) - ); - } - - #[test] - fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() { - let tmp = tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - let plugin_root = repo_root.join("plugins/demo-plugin"); - - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "codex-curated", - "plugins": [ - { - "name": "demo-plugin", - "source": { - "source": "local", - "path": "./plugins/demo-plugin" - } - } - ] -}"#, - ) - .unwrap(); - fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{ - "name": "demo-plugin", - "interface": { - "displayName": "Demo", - "capabilities": ["Interactive"], - "composerIcon": "assets/icon.png", - "logo": "/tmp/logo.png", - "screenshots": ["assets/shot1.png"] - } -}"#, - ) - .unwrap(); - - let marketplaces = - list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) - .unwrap(); - - assert_eq!( - marketplaces[0].plugins[0].interface, - Some(PluginManifestInterfaceSummary { - display_name: Some("Demo".to_string()), - short_description: None, - long_description: None, - developer_name: None, - category: None, - capabilities: vec!["Interactive".to_string()], - website_url: None, - privacy_policy_url: None, - terms_of_service_url: None, - default_prompt: None, - brand_color: None, - composer_icon: None, - logo: None, - screenshots: Vec::new(), - }) - ); - assert_eq!(marketplaces[0].plugins[0].install_policy, None); - assert_eq!(marketplaces[0].plugins[0].auth_policy, None); - } - - #[test] - fn resolve_marketplace_plugin_rejects_non_relative_local_paths() { - let tmp = tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "codex-curated", - "plugins": [ - { - "name": "local-plugin", - "source": { - "source": "local", - "path": "../plugin-1" - } - } - ] -}"#, - ) - .unwrap(); - - let err = resolve_marketplace_plugin( - &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), - "local-plugin", - ) - .unwrap_err(); - - assert_eq!( - err.to_string(), - format!( - "invalid marketplace file `{}`: local plugin source path must start with `./`", - repo_root.join(".agents/plugins/marketplace.json").display() - ) - ); - } - - #[test] - fn resolve_marketplace_plugin_uses_first_duplicate_entry() { - let tmp = tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "codex-curated", - "plugins": [ - { - "name": "local-plugin", - "source": { - "source": "local", - "path": "./first" - } - }, - { - "name": "local-plugin", - "source": { - "source": "local", - "path": "./second" - } - } - ] -}"#, - ) - .unwrap(); - - let resolved = resolve_marketplace_plugin( - &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), - "local-plugin", - ) - .unwrap(); - - assert_eq!( - resolved.source_path, - AbsolutePathBuf::try_from(repo_root.join("first")).unwrap() - ); - } -} +#[path = "marketplace_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/plugins/marketplace_tests.rs b/codex-rs/core/src/plugins/marketplace_tests.rs new file mode 100644 index 0000000000..b6b5050e8f --- /dev/null +++ b/codex-rs/core/src/plugins/marketplace_tests.rs @@ -0,0 +1,571 @@ +use super::*; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +#[test] +fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_root.join("nested")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./plugin-1" + } + } + ] +}"#, + ) + .unwrap(); + + let resolved = resolve_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap(); + + assert_eq!( + resolved, + ResolvedMarketplacePlugin { + plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string()) + .unwrap(), + source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(), + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + } + ); +} + +#[test] +fn resolve_marketplace_plugin_reports_missing_plugin() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{"name":"codex-curated","plugins":[]}"#, + ) + .unwrap(); + + let err = resolve_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "missing", + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "plugin `missing` was not found in marketplace `codex-curated`" + ); +} + +#[test] +fn list_marketplaces_returns_home_and_repo_marketplaces() { + let tmp = tempdir().unwrap(); + let home_root = tmp.path().join("home"); + let repo_root = tmp.path().join("repo"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(home_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + home_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "shared-plugin", + "source": { + "source": "local", + "path": "./home-shared" + } + }, + { + "name": "home-only", + "source": { + "source": "local", + "path": "./home-only" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "shared-plugin", + "source": { + "source": "local", + "path": "./repo-shared" + } + }, + { + "name": "repo-only", + "source": { + "source": "local", + "path": "./repo-only" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + Some(&home_root), + ) + .unwrap(); + + assert_eq!( + marketplaces, + vec![ + MarketplaceSummary { + name: "codex-curated".to_string(), + path: + AbsolutePathBuf::try_from(home_root.join(".agents/plugins/marketplace.json"),) + .unwrap(), + plugins: vec![ + MarketplacePluginSummary { + name: "shared-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(home_root.join("home-shared")).unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + }, + MarketplacePluginSummary { + name: "home-only".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(home_root.join("home-only")).unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + }, + ], + }, + MarketplaceSummary { + name: "codex-curated".to_string(), + path: + AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"),) + .unwrap(), + plugins: vec![ + MarketplacePluginSummary { + name: "shared-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(repo_root.join("repo-shared")).unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + }, + MarketplacePluginSummary { + name: "repo-only".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(repo_root.join("repo-only")).unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + }, + ], + }, + ] + ); +} + +#[test] +fn list_marketplaces_keeps_distinct_entries_for_same_name() { + let tmp = tempdir().unwrap(); + let home_root = tmp.path().join("home"); + let repo_root = tmp.path().join("repo"); + let home_marketplace = home_root.join(".agents/plugins/marketplace.json"); + let repo_marketplace = repo_root.join(".agents/plugins/marketplace.json"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(home_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + + fs::write( + home_marketplace.clone(), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./home-plugin" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + repo_marketplace.clone(), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./repo-plugin" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + Some(&home_root), + ) + .unwrap(); + + assert_eq!( + marketplaces, + vec![ + MarketplaceSummary { + name: "codex-curated".to_string(), + path: AbsolutePathBuf::try_from(home_marketplace).unwrap(), + plugins: vec![MarketplacePluginSummary { + name: "local-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + }], + }, + MarketplaceSummary { + name: "codex-curated".to_string(), + path: AbsolutePathBuf::try_from(repo_marketplace.clone()).unwrap(), + plugins: vec![MarketplacePluginSummary { + name: "local-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + }], + }, + ] + ); + + let resolved = resolve_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_marketplace).unwrap(), + "local-plugin", + ) + .unwrap(); + + assert_eq!( + resolved.source_path, + AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap() + ); +} + +#[test] +fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let nested_root = repo_root.join("nested/project"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(&nested_root).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./plugin" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[ + AbsolutePathBuf::try_from(repo_root.clone()).unwrap(), + AbsolutePathBuf::try_from(nested_root).unwrap(), + ], + None, + ) + .unwrap(); + + assert_eq!( + marketplaces, + vec![MarketplaceSummary { + name: "codex-curated".to_string(), + path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")) + .unwrap(), + plugins: vec![MarketplacePluginSummary { + name: "local-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(), + }, + install_policy: MarketplacePluginInstallPolicy::Available, + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + interface: None, + }], + }] + ); +} + +#[test] +fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("plugins/demo-plugin"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + "category": "Design" + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "interface": { + "displayName": "Demo", + "category": "Productivity", + "capabilities": ["Interactive", "Write"], + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "screenshots": ["./assets/shot1.png"] + } +}"#, + ) + .unwrap(); + + let marketplaces = + list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) + .unwrap(); + + assert_eq!( + marketplaces[0].plugins[0].install_policy, + MarketplacePluginInstallPolicy::Available + ); + assert_eq!( + marketplaces[0].plugins[0].auth_policy, + MarketplacePluginAuthPolicy::OnInstall + ); + assert_eq!( + marketplaces[0].plugins[0].interface, + Some(PluginManifestInterfaceSummary { + display_name: Some("Demo".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: Some("Design".to_string()), + capabilities: vec!["Interactive".to_string(), "Write".to_string()], + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: Some( + AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(), + ), + logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()), + screenshots: vec![ + AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(), + ], + }) + ); +} + +#[test] +fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("plugins/demo-plugin"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "interface": { + "displayName": "Demo", + "capabilities": ["Interactive"], + "composerIcon": "assets/icon.png", + "logo": "/tmp/logo.png", + "screenshots": ["assets/shot1.png"] + } +}"#, + ) + .unwrap(); + + let marketplaces = + list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) + .unwrap(); + + assert_eq!( + marketplaces[0].plugins[0].interface, + Some(PluginManifestInterfaceSummary { + display_name: Some("Demo".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: None, + capabilities: vec!["Interactive".to_string()], + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + }) + ); + assert_eq!( + marketplaces[0].plugins[0].install_policy, + MarketplacePluginInstallPolicy::Available + ); + assert_eq!( + marketplaces[0].plugins[0].auth_policy, + MarketplacePluginAuthPolicy::OnInstall + ); +} + +#[test] +fn resolve_marketplace_plugin_rejects_non_relative_local_paths() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "../plugin-1" + } + } + ] +}"#, + ) + .unwrap(); + + let err = resolve_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + format!( + "invalid marketplace file `{}`: local plugin source path must start with `./`", + repo_root.join(".agents/plugins/marketplace.json").display() + ) + ); +} + +#[test] +fn resolve_marketplace_plugin_uses_first_duplicate_entry() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./first" + } + }, + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./second" + } + } + ] +}"#, + ) + .unwrap(); + + let resolved = resolve_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap(); + + assert_eq!( + resolved.source_path, + AbsolutePathBuf::try_from(repo_root.join("first")).unwrap() + ); +} diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 2b92037d4e..1540459aa7 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -7,6 +7,7 @@ mod render; mod store; pub(crate) use curated_repo::curated_plugins_repo_path; +pub(crate) use curated_repo::read_curated_plugins_sha; pub(crate) use curated_repo::sync_openai_plugins_repo; pub(crate) use injection::build_plugin_injections; pub use manager::AppConnectorId; diff --git a/codex-rs/core/src/plugins/render.rs b/codex-rs/core/src/plugins/render.rs index 1111ea46be..4b7627a48a 100644 --- a/codex-rs/core/src/plugins/render.rs +++ b/codex-rs/core/src/plugins/render.rs @@ -79,12 +79,5 @@ pub(crate) fn render_explicit_plugin_instructions( } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn render_plugins_section_returns_none_for_empty_plugins() { - assert_eq!(render_plugins_section(&[]), None); - } -} +#[path = "render_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/plugins/render_tests.rs b/codex-rs/core/src/plugins/render_tests.rs new file mode 100644 index 0000000000..6ca86d0d41 --- /dev/null +++ b/codex-rs/core/src/plugins/render_tests.rs @@ -0,0 +1,7 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn render_plugins_section_returns_none_for_empty_plugins() { + assert_eq!(render_plugins_section(&[]), None); +} diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 0611df9a07..22452767ce 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -81,27 +81,65 @@ impl PluginStore { &self.root } - pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf { + pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf { AbsolutePathBuf::try_from( self.root .as_path() .join(&plugin_id.marketplace_name) - .join(&plugin_id.plugin_name) + .join(&plugin_id.plugin_name), + ) + .unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}")) + } + + pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf { + AbsolutePathBuf::try_from( + self.plugin_base_root(plugin_id) + .as_path() .join(plugin_version), ) .unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}")) } + pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option { + let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path()) + .ok()? + .filter_map(Result::ok) + .filter_map(|entry| { + entry.file_type().ok().filter(std::fs::FileType::is_dir)?; + entry.file_name().into_string().ok() + }) + .filter(|version| validate_plugin_segment(version, "plugin version").is_ok()) + .collect::>(); + discovered_versions.sort_unstable(); + if discovered_versions.len() == 1 { + discovered_versions.pop() + } else { + None + } + } + + pub fn active_plugin_root(&self, plugin_id: &PluginId) -> Option { + self.active_plugin_version(plugin_id) + .map(|plugin_version| self.plugin_root(plugin_id, &plugin_version)) + } + pub fn is_installed(&self, plugin_id: &PluginId) -> bool { - self.plugin_root(plugin_id, DEFAULT_PLUGIN_VERSION) - .as_path() - .is_dir() + self.active_plugin_version(plugin_id).is_some() } pub fn install( &self, source_path: AbsolutePathBuf, plugin_id: PluginId, + ) -> Result { + self.install_with_version(source_path, plugin_id, DEFAULT_PLUGIN_VERSION.to_string()) + } + + pub fn install_with_version( + &self, + source_path: AbsolutePathBuf, + plugin_id: PluginId, + plugin_version: String, ) -> Result { if !source_path.as_path().is_dir() { return Err(PluginStoreError::Invalid(format!( @@ -117,17 +155,14 @@ impl PluginStore { plugin_id.plugin_name ))); } - let plugin_version = DEFAULT_PLUGIN_VERSION.to_string(); + validate_plugin_segment(&plugin_version, "plugin version") + .map_err(PluginStoreError::Invalid)?; let installed_path = self.plugin_root(&plugin_id, &plugin_version); - - if let Some(parent) = installed_path.parent() { - fs::create_dir_all(parent.as_path()).map_err(|err| { - PluginStoreError::io("failed to create plugin cache directory", err) - })?; - } - - remove_existing_target(installed_path.as_path())?; - copy_dir_recursive(source_path.as_path(), installed_path.as_path())?; + replace_plugin_root_atomically( + source_path.as_path(), + self.plugin_base_root(&plugin_id).as_path(), + &plugin_version, + )?; Ok(PluginInstallResult { plugin_id, @@ -137,12 +172,7 @@ impl PluginStore { } pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> { - let plugin_path = self - .root - .as_path() - .join(&plugin_id.marketplace_name) - .join(&plugin_id.plugin_name); - remove_existing_target(&plugin_path) + remove_existing_target(self.plugin_base_root(plugin_id).as_path()) } } @@ -218,6 +248,73 @@ fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> { } } +fn replace_plugin_root_atomically( + source: &Path, + target_root: &Path, + plugin_version: &str, +) -> Result<(), PluginStoreError> { + let Some(parent) = target_root.parent() else { + return Err(PluginStoreError::Invalid(format!( + "plugin cache path has no parent: {}", + target_root.display() + ))); + }; + + fs::create_dir_all(parent) + .map_err(|err| PluginStoreError::io("failed to create plugin cache directory", err))?; + + let Some(plugin_dir_name) = target_root.file_name() else { + return Err(PluginStoreError::Invalid(format!( + "plugin cache path has no directory name: {}", + target_root.display() + ))); + }; + let staged_dir = tempfile::Builder::new() + .prefix("plugin-install-") + .tempdir_in(parent) + .map_err(|err| { + PluginStoreError::io("failed to create temporary plugin cache directory", err) + })?; + let staged_root = staged_dir.path().join(plugin_dir_name); + let staged_version_root = staged_root.join(plugin_version); + copy_dir_recursive(source, &staged_version_root)?; + + if target_root.exists() { + let backup_dir = tempfile::Builder::new() + .prefix("plugin-backup-") + .tempdir_in(parent) + .map_err(|err| { + PluginStoreError::io("failed to create plugin cache backup directory", err) + })?; + let backup_root = backup_dir.path().join(plugin_dir_name); + fs::rename(target_root, &backup_root) + .map_err(|err| PluginStoreError::io("failed to back up plugin cache entry", err))?; + + if let Err(err) = fs::rename(&staged_root, target_root) { + let rollback_result = fs::rename(&backup_root, target_root); + return match rollback_result { + Ok(()) => Err(PluginStoreError::io( + "failed to activate updated plugin cache entry", + err, + )), + Err(rollback_err) => { + let backup_path = backup_dir.keep().join(plugin_dir_name); + Err(PluginStoreError::Invalid(format!( + "failed to activate updated plugin cache entry at {}: {err}; failed to restore previous cache entry (left at {}): {rollback_err}", + target_root.display(), + backup_path.display() + ))) + } + }; + } + } else { + fs::rename(&staged_root, target_root) + .map_err(|err| PluginStoreError::io("failed to activate plugin cache entry", err))?; + } + + Ok(()) +} + fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> { fs::create_dir_all(target) .map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?; @@ -245,146 +342,5 @@ fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreErr } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::tempdir; - - fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { - let plugin_root = root.join(dir_name); - fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); - fs::create_dir_all(plugin_root.join("skills")).unwrap(); - fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - format!(r#"{{"name":"{manifest_name}"}}"#), - ) - .unwrap(); - fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap(); - fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); - } - - #[test] - fn install_copies_plugin_into_default_marketplace() { - let tmp = tempdir().unwrap(); - write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); - let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); - - let result = PluginStore::new(tmp.path().to_path_buf()) - .install( - AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), - plugin_id.clone(), - ) - .unwrap(); - - let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local"); - assert_eq!( - result, - PluginInstallResult { - plugin_id, - plugin_version: "local".to_string(), - installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), - } - ); - assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); - assert!(installed_path.join("skills/SKILL.md").is_file()); - } - - #[test] - fn install_uses_manifest_name_for_destination_and_key() { - let tmp = tempdir().unwrap(); - write_plugin(tmp.path(), "source-dir", "manifest-name"); - let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap(); - - let result = PluginStore::new(tmp.path().to_path_buf()) - .install( - AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), - plugin_id.clone(), - ) - .unwrap(); - - assert_eq!( - result, - PluginInstallResult { - plugin_id, - plugin_version: "local".to_string(), - installed_path: AbsolutePathBuf::try_from( - tmp.path().join("plugins/cache/market/manifest-name/local"), - ) - .unwrap(), - } - ); - } - - #[test] - fn plugin_root_derives_path_from_key_and_version() { - let tmp = tempdir().unwrap(); - let store = PluginStore::new(tmp.path().to_path_buf()); - let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap(); - - assert_eq!( - store.plugin_root(&plugin_id, "local").as_path(), - tmp.path().join("plugins/cache/debug/sample/local") - ); - } - - #[test] - fn plugin_root_rejects_path_separators_in_key_segments() { - let err = PluginId::parse("../../etc@debug").unwrap_err(); - assert_eq!( - err.to_string(), - "invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed in `../../etc@debug`" - ); - - let err = PluginId::parse("sample@../../etc").unwrap_err(); - assert_eq!( - err.to_string(), - "invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed in `sample@../../etc`" - ); - } - - #[test] - fn install_rejects_manifest_names_with_path_separators() { - let tmp = tempdir().unwrap(); - write_plugin(tmp.path(), "source-dir", "../../etc"); - - let err = PluginStore::new(tmp.path().to_path_buf()) - .install( - AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), - PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(), - ) - .unwrap_err(); - - assert_eq!( - err.to_string(), - "invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed" - ); - } - - #[test] - fn install_rejects_marketplace_names_with_path_separators() { - let err = PluginId::new("sample-plugin".to_string(), "../../etc".to_string()).unwrap_err(); - - assert_eq!( - err.to_string(), - "invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed" - ); - } - - #[test] - fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() { - let tmp = tempdir().unwrap(); - write_plugin(tmp.path(), "source-dir", "manifest-name"); - - let err = PluginStore::new(tmp.path().to_path_buf()) - .install( - AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), - PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(), - ) - .unwrap_err(); - - assert_eq!( - err.to_string(), - "plugin manifest name `manifest-name` does not match marketplace plugin name `different-name`" - ); - } -} +#[path = "store_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/plugins/store_tests.rs b/codex-rs/core/src/plugins/store_tests.rs new file mode 100644 index 0000000000..b1da11a8a6 --- /dev/null +++ b/codex-rs/core/src/plugins/store_tests.rs @@ -0,0 +1,192 @@ +use super::*; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { + let plugin_root = root.join(dir_name); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::create_dir_all(plugin_root.join("skills")).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{manifest_name}"}}"#), + ) + .unwrap(); + fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap(); + fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); +} + +#[test] +fn install_copies_plugin_into_default_marketplace() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + let result = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local"); + assert_eq!( + result, + PluginInstallResult { + plugin_id, + plugin_version: "local".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + } + ); + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); + assert!(installed_path.join("skills/SKILL.md").is_file()); +} + +#[test] +fn install_uses_manifest_name_for_destination_and_key() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "source-dir", "manifest-name"); + let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap(); + + let result = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + assert_eq!( + result, + PluginInstallResult { + plugin_id, + plugin_version: "local".to_string(), + installed_path: AbsolutePathBuf::try_from( + tmp.path().join("plugins/cache/market/manifest-name/local"), + ) + .unwrap(), + } + ); +} + +#[test] +fn plugin_root_derives_path_from_key_and_version() { + let tmp = tempdir().unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.plugin_root(&plugin_id, "local").as_path(), + tmp.path().join("plugins/cache/debug/sample/local") + ); +} + +#[test] +fn install_with_version_uses_requested_cache_version() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let plugin_id = + PluginId::new("sample-plugin".to_string(), "openai-curated".to_string()).unwrap(); + let plugin_version = "0123456789abcdef".to_string(); + + let result = PluginStore::new(tmp.path().to_path_buf()) + .install_with_version( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id.clone(), + plugin_version.clone(), + ) + .unwrap(); + + let installed_path = tmp.path().join(format!( + "plugins/cache/openai-curated/sample-plugin/{plugin_version}" + )); + assert_eq!( + result, + PluginInstallResult { + plugin_id, + plugin_version, + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + } + ); + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); +} + +#[test] +fn active_plugin_version_reads_version_directory_name() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("local".to_string()) + ); + assert_eq!( + store.active_plugin_root(&plugin_id).unwrap().as_path(), + tmp.path().join("plugins/cache/debug/sample-plugin/local") + ); +} + +#[test] +fn plugin_root_rejects_path_separators_in_key_segments() { + let err = PluginId::parse("../../etc@debug").unwrap_err(); + assert_eq!( + err.to_string(), + "invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed in `../../etc@debug`" + ); + + let err = PluginId::parse("sample@../../etc").unwrap_err(); + assert_eq!( + err.to_string(), + "invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed in `sample@../../etc`" + ); +} + +#[test] +fn install_rejects_manifest_names_with_path_separators() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "source-dir", "../../etc"); + + let err = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), + PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(), + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed" + ); +} + +#[test] +fn install_rejects_marketplace_names_with_path_separators() { + let err = PluginId::new("sample-plugin".to_string(), "../../etc".to_string()).unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed" + ); +} + +#[test] +fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "source-dir", "manifest-name"); + + let err = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), + PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(), + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "plugin manifest name `manifest-name` does not match marketplace plugin name `different-name`" + ); +} diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 0ef10535fe..1dc5189821 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -56,12 +56,13 @@ fn render_js_repl_instructions(config: &Config) -> Option { ); section.push_str("- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n"); section.push_str( - "- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n", + "- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n", ); section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n"); section.push_str("- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n"); - section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n"); - section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n"); + section.push_str("- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n"); + section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n"); + section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n"); section.push_str("- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n"); section.push_str("- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n"); section.push_str("- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n"); @@ -317,483 +318,5 @@ fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> { } #[cfg(test)] -mod tests { - use super::*; - use crate::config::ConfigBuilder; - use crate::features::Feature; - use crate::skills::loader::SkillRoot; - use crate::skills::loader::load_skills_from_roots; - use codex_protocol::protocol::SkillScope; - use std::fs; - use std::path::PathBuf; - use tempfile::TempDir; - - /// Helper that returns a `Config` pointing at `root` and using `limit` as - /// the maximum number of bytes to embed from AGENTS.md. The caller can - /// optionally specify a custom `instructions` string – when `None` the - /// value is cleared to mimic a scenario where no system instructions have - /// been configured. - async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -> Config { - let codex_home = TempDir::new().unwrap(); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("defaults for test should always succeed"); - - config.cwd = root.path().to_path_buf(); - config.project_doc_max_bytes = limit; - - config.user_instructions = instructions.map(ToOwned::to_owned); - config - } - - async fn make_config_with_fallback( - root: &TempDir, - limit: usize, - instructions: Option<&str>, - fallbacks: &[&str], - ) -> Config { - let mut config = make_config(root, limit, instructions).await; - config.project_doc_fallback_filenames = fallbacks - .iter() - .map(std::string::ToString::to_string) - .collect(); - config - } - - async fn make_config_with_project_root_markers( - root: &TempDir, - limit: usize, - instructions: Option<&str>, - markers: &[&str], - ) -> Config { - let codex_home = TempDir::new().unwrap(); - let cli_overrides = vec![( - "project_root_markers".to_string(), - TomlValue::Array( - markers - .iter() - .map(|marker| TomlValue::String((*marker).to_string())) - .collect(), - ), - )]; - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .cli_overrides(cli_overrides) - .build() - .await - .expect("defaults for test should always succeed"); - - config.cwd = root.path().to_path_buf(); - config.project_doc_max_bytes = limit; - config.user_instructions = instructions.map(ToOwned::to_owned); - config - } - - fn load_test_skills(config: &Config) -> crate::skills::SkillLoadOutcome { - load_skills_from_roots([SkillRoot { - path: config.codex_home.join("skills"), - scope: SkillScope::User, - }]) - } - - /// AGENTS.md missing – should yield `None`. - #[tokio::test] - async fn no_doc_file_returns_none() { - let tmp = tempfile::tempdir().expect("tempdir"); - - let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None, None).await; - assert!( - res.is_none(), - "Expected None when AGENTS.md is absent and no system instructions provided" - ); - assert!(res.is_none(), "Expected None when AGENTS.md is absent"); - } - - /// Small file within the byte-limit is returned unmodified. - #[tokio::test] - async fn doc_smaller_than_limit_is_returned() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join("AGENTS.md"), "hello world").unwrap(); - - let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None, None) - .await - .expect("doc expected"); - - assert_eq!( - res, "hello world", - "The document should be returned verbatim when it is smaller than the limit and there are no existing instructions" - ); - } - - /// Oversize file is truncated to `project_doc_max_bytes`. - #[tokio::test] - async fn doc_larger_than_limit_is_truncated() { - const LIMIT: usize = 1024; - let tmp = tempfile::tempdir().expect("tempdir"); - - let huge = "A".repeat(LIMIT * 2); // 2 KiB - fs::write(tmp.path().join("AGENTS.md"), &huge).unwrap(); - - let res = get_user_instructions(&make_config(&tmp, LIMIT, None).await, None, None) - .await - .expect("doc expected"); - - assert_eq!(res.len(), LIMIT, "doc should be truncated to LIMIT bytes"); - assert_eq!(res, huge[..LIMIT]); - } - - /// When `cwd` is nested inside a repo, the search should locate AGENTS.md - /// placed at the repository root (identified by `.git`). - #[tokio::test] - async fn finds_doc_in_repo_root() { - let repo = tempfile::tempdir().expect("tempdir"); - - // Simulate a git repository. Note .git can be a file or a directory. - std::fs::write( - repo.path().join(".git"), - "gitdir: /path/to/actual/git/dir\n", - ) - .unwrap(); - - // Put the doc at the repo root. - fs::write(repo.path().join("AGENTS.md"), "root level doc").unwrap(); - - // Now create a nested working directory: repo/workspace/crate_a - let nested = repo.path().join("workspace/crate_a"); - std::fs::create_dir_all(&nested).unwrap(); - - // Build config pointing at the nested dir. - let mut cfg = make_config(&repo, 4096, None).await; - cfg.cwd = nested; - - let res = get_user_instructions(&cfg, None, None) - .await - .expect("doc expected"); - assert_eq!(res, "root level doc"); - } - - /// Explicitly setting the byte-limit to zero disables project docs. - #[tokio::test] - async fn zero_byte_limit_disables_docs() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join("AGENTS.md"), "something").unwrap(); - - let res = get_user_instructions(&make_config(&tmp, 0, None).await, None, None).await; - assert!( - res.is_none(), - "With limit 0 the function should return None" - ); - } - - #[tokio::test] - async fn js_repl_instructions_are_appended_when_enabled() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cfg = make_config(&tmp, 4096, None).await; - cfg.features - .enable(Feature::JsRepl) - .expect("test config should allow js_repl"); - - let res = get_user_instructions(&cfg, None, None) - .await - .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; - assert_eq!(res, expected); - } - - #[tokio::test] - async fn js_repl_tools_only_instructions_are_feature_gated() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cfg = make_config(&tmp, 4096, None).await; - let mut features = cfg.features.get().clone(); - features - .enable(Feature::JsRepl) - .enable(Feature::JsReplToolsOnly); - cfg.features - .set(features) - .expect("test config should allow js_repl tool restrictions"); - - let res = get_user_instructions(&cfg, None, None) - .await - .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; - assert_eq!(res, expected); - } - - #[tokio::test] - async fn js_repl_image_detail_original_does_not_change_instructions() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cfg = make_config(&tmp, 4096, None).await; - let mut features = cfg.features.get().clone(); - features - .enable(Feature::JsRepl) - .enable(Feature::ImageDetailOriginal); - cfg.features - .set(features) - .expect("test config should allow js_repl image detail settings"); - - let res = get_user_instructions(&cfg, None, None) - .await - .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; - assert_eq!(res, expected); - } - - /// When both system instructions *and* a project doc are present the two - /// should be concatenated with the separator. - #[tokio::test] - async fn merges_existing_instructions_with_project_doc() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join("AGENTS.md"), "proj doc").unwrap(); - - const INSTRUCTIONS: &str = "base instructions"; - - let res = get_user_instructions( - &make_config(&tmp, 4096, Some(INSTRUCTIONS)).await, - None, - None, - ) - .await - .expect("should produce a combined instruction string"); - - let expected = format!("{INSTRUCTIONS}{PROJECT_DOC_SEPARATOR}{}", "proj doc"); - - assert_eq!(res, expected); - } - - /// If there are existing system instructions but the project doc is - /// missing we expect the original instructions to be returned unchanged. - #[tokio::test] - async fn keeps_existing_instructions_when_doc_missing() { - let tmp = tempfile::tempdir().expect("tempdir"); - - const INSTRUCTIONS: &str = "some instructions"; - - let res = get_user_instructions( - &make_config(&tmp, 4096, Some(INSTRUCTIONS)).await, - None, - None, - ) - .await; - - assert_eq!(res, Some(INSTRUCTIONS.to_string())); - } - - /// When both the repository root and the working directory contain - /// AGENTS.md files, their contents are concatenated from root to cwd. - #[tokio::test] - async fn concatenates_root_and_cwd_docs() { - let repo = tempfile::tempdir().expect("tempdir"); - - // Simulate a git repository. - std::fs::write( - repo.path().join(".git"), - "gitdir: /path/to/actual/git/dir\n", - ) - .unwrap(); - - // Repo root doc. - fs::write(repo.path().join("AGENTS.md"), "root doc").unwrap(); - - // Nested working directory with its own doc. - let nested = repo.path().join("workspace/crate_a"); - std::fs::create_dir_all(&nested).unwrap(); - fs::write(nested.join("AGENTS.md"), "crate doc").unwrap(); - - let mut cfg = make_config(&repo, 4096, None).await; - cfg.cwd = nested; - - let res = get_user_instructions(&cfg, None, None) - .await - .expect("doc expected"); - assert_eq!(res, "root doc\n\ncrate doc"); - } - - #[tokio::test] - async fn project_root_markers_are_honored_for_agents_discovery() { - let root = tempfile::tempdir().expect("tempdir"); - fs::write(root.path().join(".codex-root"), "").unwrap(); - fs::write(root.path().join("AGENTS.md"), "parent doc").unwrap(); - - let nested = root.path().join("dir1"); - fs::create_dir_all(nested.join(".git")).unwrap(); - fs::write(nested.join("AGENTS.md"), "child doc").unwrap(); - - let mut cfg = - make_config_with_project_root_markers(&root, 4096, None, &[".codex-root"]).await; - cfg.cwd = nested; - - let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); - let expected_parent = - dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"); - let expected_child = - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"); - assert_eq!(discovery.len(), 2); - assert_eq!(discovery[0], expected_parent); - assert_eq!(discovery[1], expected_child); - - let res = get_user_instructions(&cfg, None, None) - .await - .expect("doc expected"); - assert_eq!(res, "parent doc\n\nchild doc"); - } - - /// AGENTS.override.md is preferred over AGENTS.md when both are present. - #[tokio::test] - async fn agents_local_md_preferred() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join(DEFAULT_PROJECT_DOC_FILENAME), "versioned").unwrap(); - fs::write(tmp.path().join(LOCAL_PROJECT_DOC_FILENAME), "local").unwrap(); - - let cfg = make_config(&tmp, 4096, None).await; - - let res = get_user_instructions(&cfg, None, None) - .await - .expect("local doc expected"); - - assert_eq!(res, "local"); - - let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); - assert_eq!(discovery.len(), 1); - assert_eq!( - discovery[0].file_name().unwrap().to_string_lossy(), - LOCAL_PROJECT_DOC_FILENAME - ); - } - - /// When AGENTS.md is absent but a configured fallback exists, the fallback is used. - #[tokio::test] - async fn uses_configured_fallback_when_agents_missing() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join("EXAMPLE.md"), "example instructions").unwrap(); - - let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]).await; - - let res = get_user_instructions(&cfg, None, None) - .await - .expect("fallback doc expected"); - - assert_eq!(res, "example instructions"); - } - - /// AGENTS.md remains preferred when both AGENTS.md and fallbacks are present. - #[tokio::test] - async fn agents_md_preferred_over_fallbacks() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join("AGENTS.md"), "primary").unwrap(); - fs::write(tmp.path().join("EXAMPLE.md"), "secondary").unwrap(); - - let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]).await; - - let res = get_user_instructions(&cfg, None, None) - .await - .expect("AGENTS.md should win"); - - assert_eq!(res, "primary"); - - let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); - assert_eq!(discovery.len(), 1); - assert!( - discovery[0] - .file_name() - .unwrap() - .to_string_lossy() - .eq(DEFAULT_PROJECT_DOC_FILENAME) - ); - } - - #[tokio::test] - async fn skills_are_appended_to_project_doc() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap(); - - let cfg = make_config(&tmp, 4096, None).await; - create_skill( - cfg.codex_home.clone(), - "pdf-processing", - "extract from pdfs", - ); - - let skills = load_test_skills(&cfg); - let res = get_user_instructions( - &cfg, - skills.errors.is_empty().then_some(skills.skills.as_slice()), - None, - ) - .await - .expect("instructions expected"); - let expected_path = dunce::canonicalize( - cfg.codex_home - .join("skills/pdf-processing/SKILL.md") - .as_path(), - ) - .unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md")); - let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); - let usage_rules = "- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.\n 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 5) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."; - let expected = format!( - "base doc\n\n## Skills\nA skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.\n### Available skills\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n### How to use skills\n{usage_rules}" - ); - assert_eq!(res, expected); - } - - #[tokio::test] - async fn skills_render_without_project_doc() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cfg = make_config(&tmp, 4096, None).await; - create_skill(cfg.codex_home.clone(), "linting", "run clippy"); - - let skills = load_test_skills(&cfg); - let res = get_user_instructions( - &cfg, - skills.errors.is_empty().then_some(skills.skills.as_slice()), - None, - ) - .await - .expect("instructions expected"); - let expected_path = - dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path()) - .unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md")); - let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); - let usage_rules = "- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.\n 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 5) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."; - let expected = format!( - "## Skills\nA skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.\n### Available skills\n- linting: run clippy (file: {expected_path_str})\n### How to use skills\n{usage_rules}" - ); - assert_eq!(res, expected); - } - - #[tokio::test] - async fn apps_feature_does_not_emit_user_instructions_by_itself() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cfg = make_config(&tmp, 4096, None).await; - cfg.features - .enable(Feature::Apps) - .expect("test config should allow apps"); - - let res = get_user_instructions(&cfg, None, None).await; - assert_eq!(res, None); - } - - #[tokio::test] - async fn apps_feature_does_not_append_to_project_doc_user_instructions() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap(); - - let mut cfg = make_config(&tmp, 4096, None).await; - cfg.features - .enable(Feature::Apps) - .expect("test config should allow apps"); - - let res = get_user_instructions(&cfg, None, None) - .await - .expect("instructions expected"); - assert_eq!(res, "base doc"); - } - - fn create_skill(codex_home: PathBuf, name: &str, description: &str) { - let skill_dir = codex_home.join(format!("skills/{name}")); - fs::create_dir_all(&skill_dir).unwrap(); - let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); - fs::write(skill_dir.join("SKILL.md"), content).unwrap(); - } -} +#[path = "project_doc_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/project_doc_tests.rs b/codex-rs/core/src/project_doc_tests.rs new file mode 100644 index 0000000000..f801bcf139 --- /dev/null +++ b/codex-rs/core/src/project_doc_tests.rs @@ -0,0 +1,477 @@ +use super::*; +use crate::config::ConfigBuilder; +use crate::features::Feature; +use crate::skills::loader::SkillRoot; +use crate::skills::loader::load_skills_from_roots; +use codex_protocol::protocol::SkillScope; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +/// Helper that returns a `Config` pointing at `root` and using `limit` as +/// the maximum number of bytes to embed from AGENTS.md. The caller can +/// optionally specify a custom `instructions` string – when `None` the +/// value is cleared to mimic a scenario where no system instructions have +/// been configured. +async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -> Config { + let codex_home = TempDir::new().unwrap(); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("defaults for test should always succeed"); + + config.cwd = root.path().to_path_buf(); + config.project_doc_max_bytes = limit; + + config.user_instructions = instructions.map(ToOwned::to_owned); + config +} + +async fn make_config_with_fallback( + root: &TempDir, + limit: usize, + instructions: Option<&str>, + fallbacks: &[&str], +) -> Config { + let mut config = make_config(root, limit, instructions).await; + config.project_doc_fallback_filenames = fallbacks + .iter() + .map(std::string::ToString::to_string) + .collect(); + config +} + +async fn make_config_with_project_root_markers( + root: &TempDir, + limit: usize, + instructions: Option<&str>, + markers: &[&str], +) -> Config { + let codex_home = TempDir::new().unwrap(); + let cli_overrides = vec![( + "project_root_markers".to_string(), + TomlValue::Array( + markers + .iter() + .map(|marker| TomlValue::String((*marker).to_string())) + .collect(), + ), + )]; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(cli_overrides) + .build() + .await + .expect("defaults for test should always succeed"); + + config.cwd = root.path().to_path_buf(); + config.project_doc_max_bytes = limit; + config.user_instructions = instructions.map(ToOwned::to_owned); + config +} + +fn load_test_skills(config: &Config) -> crate::skills::SkillLoadOutcome { + load_skills_from_roots([SkillRoot { + path: config.codex_home.join("skills"), + scope: SkillScope::User, + }]) +} + +/// AGENTS.md missing – should yield `None`. +#[tokio::test] +async fn no_doc_file_returns_none() { + let tmp = tempfile::tempdir().expect("tempdir"); + + let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None, None).await; + assert!( + res.is_none(), + "Expected None when AGENTS.md is absent and no system instructions provided" + ); + assert!(res.is_none(), "Expected None when AGENTS.md is absent"); +} + +/// Small file within the byte-limit is returned unmodified. +#[tokio::test] +async fn doc_smaller_than_limit_is_returned() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "hello world").unwrap(); + + let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None, None) + .await + .expect("doc expected"); + + assert_eq!( + res, "hello world", + "The document should be returned verbatim when it is smaller than the limit and there are no existing instructions" + ); +} + +/// Oversize file is truncated to `project_doc_max_bytes`. +#[tokio::test] +async fn doc_larger_than_limit_is_truncated() { + const LIMIT: usize = 1024; + let tmp = tempfile::tempdir().expect("tempdir"); + + let huge = "A".repeat(LIMIT * 2); // 2 KiB + fs::write(tmp.path().join("AGENTS.md"), &huge).unwrap(); + + let res = get_user_instructions(&make_config(&tmp, LIMIT, None).await, None, None) + .await + .expect("doc expected"); + + assert_eq!(res.len(), LIMIT, "doc should be truncated to LIMIT bytes"); + assert_eq!(res, huge[..LIMIT]); +} + +/// When `cwd` is nested inside a repo, the search should locate AGENTS.md +/// placed at the repository root (identified by `.git`). +#[tokio::test] +async fn finds_doc_in_repo_root() { + let repo = tempfile::tempdir().expect("tempdir"); + + // Simulate a git repository. Note .git can be a file or a directory. + std::fs::write( + repo.path().join(".git"), + "gitdir: /path/to/actual/git/dir\n", + ) + .unwrap(); + + // Put the doc at the repo root. + fs::write(repo.path().join("AGENTS.md"), "root level doc").unwrap(); + + // Now create a nested working directory: repo/workspace/crate_a + let nested = repo.path().join("workspace/crate_a"); + std::fs::create_dir_all(&nested).unwrap(); + + // Build config pointing at the nested dir. + let mut cfg = make_config(&repo, 4096, None).await; + cfg.cwd = nested; + + let res = get_user_instructions(&cfg, None, None) + .await + .expect("doc expected"); + assert_eq!(res, "root level doc"); +} + +/// Explicitly setting the byte-limit to zero disables project docs. +#[tokio::test] +async fn zero_byte_limit_disables_docs() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "something").unwrap(); + + let res = get_user_instructions(&make_config(&tmp, 0, None).await, None, None).await; + assert!( + res.is_none(), + "With limit 0 the function should return None" + ); +} + +#[tokio::test] +async fn js_repl_instructions_are_appended_when_enabled() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut cfg = make_config(&tmp, 4096, None).await; + cfg.features + .enable(Feature::JsRepl) + .expect("test config should allow js_repl"); + + let res = get_user_instructions(&cfg, None, None) + .await + .expect("js_repl instructions expected"); + let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; + assert_eq!(res, expected); +} + +#[tokio::test] +async fn js_repl_tools_only_instructions_are_feature_gated() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut cfg = make_config(&tmp, 4096, None).await; + let mut features = cfg.features.get().clone(); + features + .enable(Feature::JsRepl) + .enable(Feature::JsReplToolsOnly); + cfg.features + .set(features) + .expect("test config should allow js_repl tool restrictions"); + + let res = get_user_instructions(&cfg, None, None) + .await + .expect("js_repl instructions expected"); + let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; + assert_eq!(res, expected); +} + +#[tokio::test] +async fn js_repl_image_detail_original_does_not_change_instructions() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut cfg = make_config(&tmp, 4096, None).await; + let mut features = cfg.features.get().clone(); + features + .enable(Feature::JsRepl) + .enable(Feature::ImageDetailOriginal); + cfg.features + .set(features) + .expect("test config should allow js_repl image detail settings"); + + let res = get_user_instructions(&cfg, None, None) + .await + .expect("js_repl instructions expected"); + let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; + assert_eq!(res, expected); +} + +/// When both system instructions *and* a project doc are present the two +/// should be concatenated with the separator. +#[tokio::test] +async fn merges_existing_instructions_with_project_doc() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "proj doc").unwrap(); + + const INSTRUCTIONS: &str = "base instructions"; + + let res = get_user_instructions( + &make_config(&tmp, 4096, Some(INSTRUCTIONS)).await, + None, + None, + ) + .await + .expect("should produce a combined instruction string"); + + let expected = format!("{INSTRUCTIONS}{PROJECT_DOC_SEPARATOR}{}", "proj doc"); + + assert_eq!(res, expected); +} + +/// If there are existing system instructions but the project doc is +/// missing we expect the original instructions to be returned unchanged. +#[tokio::test] +async fn keeps_existing_instructions_when_doc_missing() { + let tmp = tempfile::tempdir().expect("tempdir"); + + const INSTRUCTIONS: &str = "some instructions"; + + let res = get_user_instructions( + &make_config(&tmp, 4096, Some(INSTRUCTIONS)).await, + None, + None, + ) + .await; + + assert_eq!(res, Some(INSTRUCTIONS.to_string())); +} + +/// When both the repository root and the working directory contain +/// AGENTS.md files, their contents are concatenated from root to cwd. +#[tokio::test] +async fn concatenates_root_and_cwd_docs() { + let repo = tempfile::tempdir().expect("tempdir"); + + // Simulate a git repository. + std::fs::write( + repo.path().join(".git"), + "gitdir: /path/to/actual/git/dir\n", + ) + .unwrap(); + + // Repo root doc. + fs::write(repo.path().join("AGENTS.md"), "root doc").unwrap(); + + // Nested working directory with its own doc. + let nested = repo.path().join("workspace/crate_a"); + std::fs::create_dir_all(&nested).unwrap(); + fs::write(nested.join("AGENTS.md"), "crate doc").unwrap(); + + let mut cfg = make_config(&repo, 4096, None).await; + cfg.cwd = nested; + + let res = get_user_instructions(&cfg, None, None) + .await + .expect("doc expected"); + assert_eq!(res, "root doc\n\ncrate doc"); +} + +#[tokio::test] +async fn project_root_markers_are_honored_for_agents_discovery() { + let root = tempfile::tempdir().expect("tempdir"); + fs::write(root.path().join(".codex-root"), "").unwrap(); + fs::write(root.path().join("AGENTS.md"), "parent doc").unwrap(); + + let nested = root.path().join("dir1"); + fs::create_dir_all(nested.join(".git")).unwrap(); + fs::write(nested.join("AGENTS.md"), "child doc").unwrap(); + + let mut cfg = make_config_with_project_root_markers(&root, 4096, None, &[".codex-root"]).await; + cfg.cwd = nested; + + let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); + let expected_parent = + dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"); + let expected_child = + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"); + assert_eq!(discovery.len(), 2); + assert_eq!(discovery[0], expected_parent); + assert_eq!(discovery[1], expected_child); + + let res = get_user_instructions(&cfg, None, None) + .await + .expect("doc expected"); + assert_eq!(res, "parent doc\n\nchild doc"); +} + +/// AGENTS.override.md is preferred over AGENTS.md when both are present. +#[tokio::test] +async fn agents_local_md_preferred() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join(DEFAULT_PROJECT_DOC_FILENAME), "versioned").unwrap(); + fs::write(tmp.path().join(LOCAL_PROJECT_DOC_FILENAME), "local").unwrap(); + + let cfg = make_config(&tmp, 4096, None).await; + + let res = get_user_instructions(&cfg, None, None) + .await + .expect("local doc expected"); + + assert_eq!(res, "local"); + + let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); + assert_eq!(discovery.len(), 1); + assert_eq!( + discovery[0].file_name().unwrap().to_string_lossy(), + LOCAL_PROJECT_DOC_FILENAME + ); +} + +/// When AGENTS.md is absent but a configured fallback exists, the fallback is used. +#[tokio::test] +async fn uses_configured_fallback_when_agents_missing() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("EXAMPLE.md"), "example instructions").unwrap(); + + let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]).await; + + let res = get_user_instructions(&cfg, None, None) + .await + .expect("fallback doc expected"); + + assert_eq!(res, "example instructions"); +} + +/// AGENTS.md remains preferred when both AGENTS.md and fallbacks are present. +#[tokio::test] +async fn agents_md_preferred_over_fallbacks() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "primary").unwrap(); + fs::write(tmp.path().join("EXAMPLE.md"), "secondary").unwrap(); + + let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]).await; + + let res = get_user_instructions(&cfg, None, None) + .await + .expect("AGENTS.md should win"); + + assert_eq!(res, "primary"); + + let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); + assert_eq!(discovery.len(), 1); + assert!( + discovery[0] + .file_name() + .unwrap() + .to_string_lossy() + .eq(DEFAULT_PROJECT_DOC_FILENAME) + ); +} + +#[tokio::test] +async fn skills_are_appended_to_project_doc() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap(); + + let cfg = make_config(&tmp, 4096, None).await; + create_skill( + cfg.codex_home.clone(), + "pdf-processing", + "extract from pdfs", + ); + + let skills = load_test_skills(&cfg); + let res = get_user_instructions( + &cfg, + skills.errors.is_empty().then_some(skills.skills.as_slice()), + None, + ) + .await + .expect("instructions expected"); + let expected_path = dunce::canonicalize( + cfg.codex_home + .join("skills/pdf-processing/SKILL.md") + .as_path(), + ) + .unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md")); + let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); + let usage_rules = "- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.\n 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 5) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."; + let expected = format!( + "base doc\n\n## Skills\nA skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.\n### Available skills\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n### How to use skills\n{usage_rules}" + ); + assert_eq!(res, expected); +} + +#[tokio::test] +async fn skills_render_without_project_doc() { + let tmp = tempfile::tempdir().expect("tempdir"); + let cfg = make_config(&tmp, 4096, None).await; + create_skill(cfg.codex_home.clone(), "linting", "run clippy"); + + let skills = load_test_skills(&cfg); + let res = get_user_instructions( + &cfg, + skills.errors.is_empty().then_some(skills.skills.as_slice()), + None, + ) + .await + .expect("instructions expected"); + let expected_path = + dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path()) + .unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md")); + let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); + let usage_rules = "- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.\n 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 5) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."; + let expected = format!( + "## Skills\nA skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.\n### Available skills\n- linting: run clippy (file: {expected_path_str})\n### How to use skills\n{usage_rules}" + ); + assert_eq!(res, expected); +} + +#[tokio::test] +async fn apps_feature_does_not_emit_user_instructions_by_itself() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut cfg = make_config(&tmp, 4096, None).await; + cfg.features + .enable(Feature::Apps) + .expect("test config should allow apps"); + + let res = get_user_instructions(&cfg, None, None).await; + assert_eq!(res, None); +} + +#[tokio::test] +async fn apps_feature_does_not_append_to_project_doc_user_instructions() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap(); + + let mut cfg = make_config(&tmp, 4096, None).await; + cfg.features + .enable(Feature::Apps) + .expect("test config should allow apps"); + + let res = get_user_instructions(&cfg, None, None) + .await + .expect("instructions expected"); + assert_eq!(res, "base doc"); +} + +fn create_skill(codex_home: PathBuf, name: &str, description: &str) { + let skill_dir = codex_home.join(format!("skills/{name}")); + fs::create_dir_all(&skill_dir).unwrap(); + let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); + fs::write(skill_dir.join("SKILL.md"), content).unwrap(); +} diff --git a/codex-rs/core/src/realtime_context.rs b/codex-rs/core/src/realtime_context.rs index e15adabc4e..8016763514 100644 --- a/codex-rs/core/src/realtime_context.rs +++ b/codex-rs/core/src/realtime_context.rs @@ -395,138 +395,5 @@ fn approx_token_count(text: &str) -> usize { } #[cfg(test)] -mod tests { - use super::build_recent_work_section; - use super::build_workspace_section; - use super::build_workspace_section_with_user_root; - use chrono::TimeZone; - use chrono::Utc; - use codex_protocol::ThreadId; - use codex_state::ThreadMetadata; - use pretty_assertions::assert_eq; - use std::fs; - use std::path::PathBuf; - use std::process::Command; - use tempfile::TempDir; - - fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMetadata { - ThreadMetadata { - id: ThreadId::new(), - rollout_path: PathBuf::from("/tmp/rollout.jsonl"), - created_at: Utc - .timestamp_opt(1_709_251_100, 0) - .single() - .expect("valid timestamp"), - updated_at: Utc - .timestamp_opt(1_709_251_200, 0) - .single() - .expect("valid timestamp"), - source: "cli".to_string(), - agent_nickname: None, - agent_role: None, - model_provider: "test-provider".to_string(), - cwd: PathBuf::from(cwd), - cli_version: "test".to_string(), - title: title.to_string(), - sandbox_policy: "workspace-write".to_string(), - approval_mode: "never".to_string(), - tokens_used: 0, - first_user_message: Some(first_user_message.to_string()), - archived_at: None, - git_sha: None, - git_branch: Some("main".to_string()), - git_origin_url: None, - } - } - - #[test] - fn workspace_section_requires_meaningful_structure() { - let cwd = TempDir::new().expect("tempdir"); - assert_eq!( - build_workspace_section_with_user_root(cwd.path(), None), - None - ); - } - - #[test] - fn workspace_section_includes_tree_when_entries_exist() { - let cwd = TempDir::new().expect("tempdir"); - fs::create_dir(cwd.path().join("docs")).expect("create docs dir"); - fs::write(cwd.path().join("README.md"), "hello").expect("write readme"); - - let section = build_workspace_section(cwd.path()).expect("workspace section"); - assert!(section.contains("Working directory tree:")); - assert!(section.contains("- docs/")); - assert!(section.contains("- README.md")); - } - - #[test] - fn workspace_section_includes_user_root_tree_when_distinct() { - let root = TempDir::new().expect("tempdir"); - let cwd = root.path().join("cwd"); - let git_root = root.path().join("git"); - let user_root = root.path().join("home"); - - fs::create_dir_all(cwd.join("docs")).expect("create cwd docs dir"); - fs::write(cwd.join("README.md"), "hello").expect("write cwd readme"); - fs::create_dir_all(git_root.join(".git")).expect("create git dir"); - fs::write(git_root.join("Cargo.toml"), "[workspace]").expect("write git root marker"); - fs::create_dir_all(user_root.join("code")).expect("create user root child"); - fs::write(user_root.join(".zshrc"), "export TEST=1").expect("write home file"); - - let section = build_workspace_section_with_user_root(cwd.as_path(), Some(user_root)) - .expect("workspace section"); - assert!(section.contains("User root tree:")); - assert!(section.contains("- code/")); - assert!(!section.contains("- .zshrc")); - } - - #[test] - fn recent_work_section_groups_threads_by_cwd() { - let root = TempDir::new().expect("tempdir"); - let repo = root.path().join("repo"); - let workspace_a = repo.join("workspace-a"); - let workspace_b = repo.join("workspace-b"); - let outside = root.path().join("outside"); - - fs::create_dir(&repo).expect("create repo dir"); - Command::new("git") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1") - .args(["init"]) - .current_dir(&repo) - .output() - .expect("git init"); - fs::create_dir_all(&workspace_a).expect("create workspace a"); - fs::create_dir_all(&workspace_b).expect("create workspace b"); - fs::create_dir_all(&outside).expect("create outside dir"); - - let recent_threads = vec![ - thread_metadata( - workspace_a.to_string_lossy().as_ref(), - "Investigate realtime startup context", - "Log the startup context before sending it", - ), - thread_metadata( - workspace_b.to_string_lossy().as_ref(), - "Trim websocket startup payload", - "Remove memories from the realtime startup context", - ), - thread_metadata(outside.to_string_lossy().as_ref(), "", "Inspect flaky test"), - ]; - let current_cwd = workspace_a; - let repo = fs::canonicalize(repo).expect("canonicalize repo"); - - let section = build_recent_work_section(current_cwd.as_path(), &recent_threads) - .expect("recent work section"); - assert!(section.contains(&format!("### Git repo: {}", repo.display()))); - assert!(section.contains("Recent sessions: 2")); - assert!(section.contains("User asks:")); - assert!(section.contains(&format!( - "- {}: Log the startup context before sending it", - current_cwd.display() - ))); - assert!(section.contains(&format!("### Directory: {}", outside.display()))); - assert!(section.contains(&format!("- {}: Inspect flaky test", outside.display()))); - } -} +#[path = "realtime_context_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs new file mode 100644 index 0000000000..b23c2743cf --- /dev/null +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -0,0 +1,133 @@ +use super::build_recent_work_section; +use super::build_workspace_section; +use super::build_workspace_section_with_user_root; +use chrono::TimeZone; +use chrono::Utc; +use codex_protocol::ThreadId; +use codex_state::ThreadMetadata; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use tempfile::TempDir; + +fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMetadata { + ThreadMetadata { + id: ThreadId::new(), + rollout_path: PathBuf::from("/tmp/rollout.jsonl"), + created_at: Utc + .timestamp_opt(1_709_251_100, 0) + .single() + .expect("valid timestamp"), + updated_at: Utc + .timestamp_opt(1_709_251_200, 0) + .single() + .expect("valid timestamp"), + source: "cli".to_string(), + agent_nickname: None, + agent_role: None, + model_provider: "test-provider".to_string(), + cwd: PathBuf::from(cwd), + cli_version: "test".to_string(), + title: title.to_string(), + sandbox_policy: "workspace-write".to_string(), + approval_mode: "never".to_string(), + tokens_used: 0, + first_user_message: Some(first_user_message.to_string()), + archived_at: None, + git_sha: None, + git_branch: Some("main".to_string()), + git_origin_url: None, + } +} + +#[test] +fn workspace_section_requires_meaningful_structure() { + let cwd = TempDir::new().expect("tempdir"); + assert_eq!( + build_workspace_section_with_user_root(cwd.path(), None), + None + ); +} + +#[test] +fn workspace_section_includes_tree_when_entries_exist() { + let cwd = TempDir::new().expect("tempdir"); + fs::create_dir(cwd.path().join("docs")).expect("create docs dir"); + fs::write(cwd.path().join("README.md"), "hello").expect("write readme"); + + let section = build_workspace_section(cwd.path()).expect("workspace section"); + assert!(section.contains("Working directory tree:")); + assert!(section.contains("- docs/")); + assert!(section.contains("- README.md")); +} + +#[test] +fn workspace_section_includes_user_root_tree_when_distinct() { + let root = TempDir::new().expect("tempdir"); + let cwd = root.path().join("cwd"); + let git_root = root.path().join("git"); + let user_root = root.path().join("home"); + + fs::create_dir_all(cwd.join("docs")).expect("create cwd docs dir"); + fs::write(cwd.join("README.md"), "hello").expect("write cwd readme"); + fs::create_dir_all(git_root.join(".git")).expect("create git dir"); + fs::write(git_root.join("Cargo.toml"), "[workspace]").expect("write git root marker"); + fs::create_dir_all(user_root.join("code")).expect("create user root child"); + fs::write(user_root.join(".zshrc"), "export TEST=1").expect("write home file"); + + let section = build_workspace_section_with_user_root(cwd.as_path(), Some(user_root)) + .expect("workspace section"); + assert!(section.contains("User root tree:")); + assert!(section.contains("- code/")); + assert!(!section.contains("- .zshrc")); +} + +#[test] +fn recent_work_section_groups_threads_by_cwd() { + let root = TempDir::new().expect("tempdir"); + let repo = root.path().join("repo"); + let workspace_a = repo.join("workspace-a"); + let workspace_b = repo.join("workspace-b"); + let outside = root.path().join("outside"); + + fs::create_dir(&repo).expect("create repo dir"); + Command::new("git") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .args(["init"]) + .current_dir(&repo) + .output() + .expect("git init"); + fs::create_dir_all(&workspace_a).expect("create workspace a"); + fs::create_dir_all(&workspace_b).expect("create workspace b"); + fs::create_dir_all(&outside).expect("create outside dir"); + + let recent_threads = vec![ + thread_metadata( + workspace_a.to_string_lossy().as_ref(), + "Investigate realtime startup context", + "Log the startup context before sending it", + ), + thread_metadata( + workspace_b.to_string_lossy().as_ref(), + "Trim websocket startup payload", + "Remove memories from the realtime startup context", + ), + thread_metadata(outside.to_string_lossy().as_ref(), "", "Inspect flaky test"), + ]; + let current_cwd = workspace_a; + let repo = fs::canonicalize(repo).expect("canonicalize repo"); + + let section = build_recent_work_section(current_cwd.as_path(), &recent_threads) + .expect("recent work section"); + assert!(section.contains(&format!("### Git repo: {}", repo.display()))); + assert!(section.contains("Recent sessions: 2")); + assert!(section.contains("User asks:")); + assert!(section.contains(&format!( + "- {}: Log the startup context before sending it", + current_cwd.display() + ))); + assert!(section.contains(&format!("### Directory: {}", outside.display()))); + assert!(section.contains(&format!("- {}: Inspect flaky test", outside.display()))); +} diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 3baea265ad..f1ce8398e3 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -600,119 +600,5 @@ async fn send_conversation_error( } #[cfg(test)] -mod tests { - use super::HandoffOutput; - use super::RealtimeHandoffState; - use super::realtime_text_from_handoff_request; - use async_channel::bounded; - use codex_protocol::protocol::RealtimeHandoffRequested; - use codex_protocol::protocol::RealtimeTranscriptEntry; - use pretty_assertions::assert_eq; - - #[test] - fn extracts_text_from_handoff_request_active_transcript() { - let handoff = RealtimeHandoffRequested { - handoff_id: "handoff_1".to_string(), - item_id: "item_1".to_string(), - input_transcript: "ignored".to_string(), - active_transcript: vec![ - RealtimeTranscriptEntry { - role: "user".to_string(), - text: "hello".to_string(), - }, - RealtimeTranscriptEntry { - role: "assistant".to_string(), - text: "hi there".to_string(), - }, - ], - }; - assert_eq!( - realtime_text_from_handoff_request(&handoff), - Some("user: hello\nassistant: hi there".to_string()) - ); - } - - #[test] - fn extracts_text_from_handoff_request_input_transcript_if_messages_missing() { - let handoff = RealtimeHandoffRequested { - handoff_id: "handoff_1".to_string(), - item_id: "item_1".to_string(), - input_transcript: "ignored".to_string(), - active_transcript: vec![], - }; - assert_eq!( - realtime_text_from_handoff_request(&handoff), - Some("ignored".to_string()) - ); - } - - #[test] - fn ignores_empty_handoff_request_input_transcript() { - let handoff = RealtimeHandoffRequested { - handoff_id: "handoff_1".to_string(), - item_id: "item_1".to_string(), - input_transcript: String::new(), - active_transcript: vec![], - }; - assert_eq!(realtime_text_from_handoff_request(&handoff), None); - } - - #[tokio::test] - async fn clears_active_handoff_explicitly() { - let (tx, _rx) = bounded(1); - let state = RealtimeHandoffState::new(tx); - - *state.active_handoff.lock().await = Some("handoff_1".to_string()); - assert_eq!( - state.active_handoff.lock().await.clone(), - Some("handoff_1".to_string()) - ); - - *state.active_handoff.lock().await = None; - assert_eq!(state.active_handoff.lock().await.clone(), None); - } - - #[tokio::test] - async fn sends_multiple_handoff_outputs_until_cleared() { - let (tx, rx) = bounded(4); - let state = RealtimeHandoffState::new(tx); - - state - .send_output("ignored".to_string()) - .await - .expect("send"); - assert!(rx.is_empty()); - - *state.active_handoff.lock().await = Some("handoff_1".to_string()); - state.send_output("result".to_string()).await.expect("send"); - state - .send_output("result 2".to_string()) - .await - .expect("send"); - - let output_1 = rx.recv().await.expect("recv"); - assert_eq!( - output_1, - HandoffOutput { - handoff_id: "handoff_1".to_string(), - output_text: "result".to_string(), - } - ); - - let output_2 = rx.recv().await.expect("recv"); - assert_eq!( - output_2, - HandoffOutput { - handoff_id: "handoff_1".to_string(), - output_text: "result 2".to_string(), - } - ); - - *state.active_handoff.lock().await = None; - state - .send_output("ignored after clear".to_string()) - .await - .expect("send"); - assert!(rx.is_empty()); - } -} +#[path = "realtime_conversation_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/realtime_conversation_tests.rs b/codex-rs/core/src/realtime_conversation_tests.rs new file mode 100644 index 0000000000..d6b85a92da --- /dev/null +++ b/codex-rs/core/src/realtime_conversation_tests.rs @@ -0,0 +1,114 @@ +use super::HandoffOutput; +use super::RealtimeHandoffState; +use super::realtime_text_from_handoff_request; +use async_channel::bounded; +use codex_protocol::protocol::RealtimeHandoffRequested; +use codex_protocol::protocol::RealtimeTranscriptEntry; +use pretty_assertions::assert_eq; + +#[test] +fn extracts_text_from_handoff_request_active_transcript() { + let handoff = RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_1".to_string(), + input_transcript: "ignored".to_string(), + active_transcript: vec![ + RealtimeTranscriptEntry { + role: "user".to_string(), + text: "hello".to_string(), + }, + RealtimeTranscriptEntry { + role: "assistant".to_string(), + text: "hi there".to_string(), + }, + ], + }; + assert_eq!( + realtime_text_from_handoff_request(&handoff), + Some("user: hello\nassistant: hi there".to_string()) + ); +} + +#[test] +fn extracts_text_from_handoff_request_input_transcript_if_messages_missing() { + let handoff = RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_1".to_string(), + input_transcript: "ignored".to_string(), + active_transcript: vec![], + }; + assert_eq!( + realtime_text_from_handoff_request(&handoff), + Some("ignored".to_string()) + ); +} + +#[test] +fn ignores_empty_handoff_request_input_transcript() { + let handoff = RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_1".to_string(), + input_transcript: String::new(), + active_transcript: vec![], + }; + assert_eq!(realtime_text_from_handoff_request(&handoff), None); +} + +#[tokio::test] +async fn clears_active_handoff_explicitly() { + let (tx, _rx) = bounded(1); + let state = RealtimeHandoffState::new(tx); + + *state.active_handoff.lock().await = Some("handoff_1".to_string()); + assert_eq!( + state.active_handoff.lock().await.clone(), + Some("handoff_1".to_string()) + ); + + *state.active_handoff.lock().await = None; + assert_eq!(state.active_handoff.lock().await.clone(), None); +} + +#[tokio::test] +async fn sends_multiple_handoff_outputs_until_cleared() { + let (tx, rx) = bounded(4); + let state = RealtimeHandoffState::new(tx); + + state + .send_output("ignored".to_string()) + .await + .expect("send"); + assert!(rx.is_empty()); + + *state.active_handoff.lock().await = Some("handoff_1".to_string()); + state.send_output("result".to_string()).await.expect("send"); + state + .send_output("result 2".to_string()) + .await + .expect("send"); + + let output_1 = rx.recv().await.expect("recv"); + assert_eq!( + output_1, + HandoffOutput { + handoff_id: "handoff_1".to_string(), + output_text: "result".to_string(), + } + ); + + let output_2 = rx.recv().await.expect("recv"); + assert_eq!( + output_2, + HandoffOutput { + handoff_id: "handoff_1".to_string(), + output_text: "result 2".to_string(), + } + ); + + *state.active_handoff.lock().await = None; + state + .send_output("ignored after clear".to_string()) + .await + .expect("send"); + assert!(rx.is_empty()); +} diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/core/src/rollout/metadata.rs index 3b18520ee0..d2edfbb0d8 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/core/src/rollout/metadata.rs @@ -437,387 +437,5 @@ async fn collect_rollout_paths(root: &Path) -> std::io::Result> { } #[cfg(test)] -mod tests { - use super::*; - use chrono::DateTime; - use chrono::NaiveDateTime; - use chrono::Timelike; - use chrono::Utc; - use codex_protocol::ThreadId; - use codex_protocol::protocol::CompactedItem; - use codex_protocol::protocol::GitInfo; - use codex_protocol::protocol::RolloutItem; - use codex_protocol::protocol::RolloutLine; - use codex_protocol::protocol::SessionMeta; - use codex_protocol::protocol::SessionMetaLine; - use codex_protocol::protocol::SessionSource; - use codex_state::BackfillStatus; - use codex_state::ThreadMetadataBuilder; - use pretty_assertions::assert_eq; - use std::fs::File; - use std::io::Write; - use std::path::Path; - use std::path::PathBuf; - use tempfile::tempdir; - use uuid::Uuid; - - #[tokio::test] - async fn extract_metadata_from_rollout_uses_session_meta() { - let dir = tempdir().expect("tempdir"); - let uuid = Uuid::new_v4(); - let id = ThreadId::from_string(&uuid.to_string()).expect("thread id"); - let path = dir - .path() - .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); - - let session_meta = SessionMeta { - id, - forked_from_id: None, - timestamp: "2026-01-27T12:34:56Z".to_string(), - cwd: dir.path().to_path_buf(), - originator: "cli".to_string(), - cli_version: "0.0.0".to_string(), - source: SessionSource::default(), - agent_nickname: None, - agent_role: None, - model_provider: Some("openai".to_string()), - base_instructions: None, - dynamic_tools: None, - memory_mode: None, - }; - let session_meta_line = SessionMetaLine { - meta: session_meta, - git: None, - }; - let rollout_line = RolloutLine { - timestamp: "2026-01-27T12:34:56Z".to_string(), - item: RolloutItem::SessionMeta(session_meta_line.clone()), - }; - let json = serde_json::to_string(&rollout_line).expect("rollout json"); - let mut file = File::create(&path).expect("create rollout"); - writeln!(file, "{json}").expect("write rollout"); - - let outcome = extract_metadata_from_rollout(&path, "openai") - .await - .expect("extract"); - - let builder = - builder_from_session_meta(&session_meta_line, path.as_path()).expect("builder"); - let mut expected = builder.build("openai"); - apply_rollout_item(&mut expected, &rollout_line.item, "openai"); - expected.updated_at = file_modified_time_utc(&path).await.expect("mtime"); - - assert_eq!(outcome.metadata, expected); - assert_eq!(outcome.memory_mode, None); - assert_eq!(outcome.parse_errors, 0); - } - - #[tokio::test] - async fn extract_metadata_from_rollout_returns_latest_memory_mode() { - let dir = tempdir().expect("tempdir"); - let uuid = Uuid::new_v4(); - let id = ThreadId::from_string(&uuid.to_string()).expect("thread id"); - let path = dir - .path() - .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); - - let session_meta = SessionMeta { - id, - forked_from_id: None, - timestamp: "2026-01-27T12:34:56Z".to_string(), - cwd: dir.path().to_path_buf(), - originator: "cli".to_string(), - cli_version: "0.0.0".to_string(), - source: SessionSource::default(), - agent_nickname: None, - agent_role: None, - model_provider: Some("openai".to_string()), - base_instructions: None, - dynamic_tools: None, - memory_mode: None, - }; - let polluted_meta = SessionMeta { - memory_mode: Some("polluted".to_string()), - ..session_meta.clone() - }; - let lines = vec![ - RolloutLine { - timestamp: "2026-01-27T12:34:56Z".to_string(), - item: RolloutItem::SessionMeta(SessionMetaLine { - meta: session_meta, - git: None, - }), - }, - RolloutLine { - timestamp: "2026-01-27T12:35:00Z".to_string(), - item: RolloutItem::SessionMeta(SessionMetaLine { - meta: polluted_meta, - git: None, - }), - }, - ]; - let mut file = File::create(&path).expect("create rollout"); - for line in lines { - writeln!( - file, - "{}", - serde_json::to_string(&line).expect("serialize rollout line") - ) - .expect("write rollout line"); - } - - let outcome = extract_metadata_from_rollout(&path, "openai") - .await - .expect("extract"); - - assert_eq!(outcome.memory_mode.as_deref(), Some("polluted")); - } - - #[test] - fn builder_from_items_falls_back_to_filename() { - let dir = tempdir().expect("tempdir"); - let uuid = Uuid::new_v4(); - let path = dir - .path() - .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); - let items = vec![RolloutItem::Compacted(CompactedItem { - message: "noop".to_string(), - replacement_history: None, - })]; - - let builder = builder_from_items(items.as_slice(), path.as_path()).expect("builder"); - let naive = NaiveDateTime::parse_from_str("2026-01-27T12-34-56", "%Y-%m-%dT%H-%M-%S") - .expect("timestamp"); - let created_at = DateTime::::from_naive_utc_and_offset(naive, Utc) - .with_nanosecond(0) - .expect("nanosecond"); - let expected = ThreadMetadataBuilder::new( - ThreadId::from_string(&uuid.to_string()).expect("thread id"), - path, - created_at, - SessionSource::default(), - ); - - assert_eq!(builder, expected); - } - - #[tokio::test] - async fn backfill_sessions_resumes_from_watermark_and_marks_complete() { - let dir = tempdir().expect("tempdir"); - let codex_home = dir.path().to_path_buf(); - let first_uuid = Uuid::new_v4(); - let second_uuid = Uuid::new_v4(); - let first_path = write_rollout_in_sessions( - codex_home.as_path(), - "2026-01-27T12-34-56", - "2026-01-27T12:34:56Z", - first_uuid, - None, - ); - let second_path = write_rollout_in_sessions( - codex_home.as_path(), - "2026-01-27T12-35-56", - "2026-01-27T12:35:56Z", - second_uuid, - None, - ); - - let runtime = - codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string()) - .await - .expect("initialize runtime"); - let first_watermark = - backfill_watermark_for_path(codex_home.as_path(), first_path.as_path()); - runtime.mark_backfill_running().await.expect("mark running"); - runtime - .checkpoint_backfill(first_watermark.as_str()) - .await - .expect("checkpoint first watermark"); - tokio::time::sleep(std::time::Duration::from_secs( - (BACKFILL_LEASE_SECONDS + 1) as u64, - )) - .await; - - let mut config = crate::config::test_config(); - config.codex_home = codex_home.clone(); - config.model_provider_id = "test-provider".to_string(); - backfill_sessions(runtime.as_ref(), &config).await; - - let first_id = ThreadId::from_string(&first_uuid.to_string()).expect("first thread id"); - let second_id = ThreadId::from_string(&second_uuid.to_string()).expect("second thread id"); - assert_eq!( - runtime - .get_thread(first_id) - .await - .expect("get first thread"), - None - ); - assert!( - runtime - .get_thread(second_id) - .await - .expect("get second thread") - .is_some() - ); - - let state = runtime - .get_backfill_state() - .await - .expect("get backfill state"); - assert_eq!(state.status, BackfillStatus::Complete); - assert_eq!( - state.last_watermark, - Some(backfill_watermark_for_path( - codex_home.as_path(), - second_path.as_path() - )) - ); - assert!(state.last_success_at.is_some()); - } - - #[tokio::test] - async fn backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_fields() { - let dir = tempdir().expect("tempdir"); - let codex_home = dir.path().to_path_buf(); - let thread_uuid = Uuid::new_v4(); - let rollout_path = write_rollout_in_sessions( - codex_home.as_path(), - "2026-01-27T12-34-56", - "2026-01-27T12:34:56Z", - thread_uuid, - Some(GitInfo { - commit_hash: Some("rollout-sha".to_string()), - branch: Some("rollout-branch".to_string()), - repository_url: Some("git@example.com:openai/codex.git".to_string()), - }), - ); - - let runtime = - codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string()) - .await - .expect("initialize runtime"); - let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); - let mut existing = extract_metadata_from_rollout(&rollout_path, "test-provider") - .await - .expect("extract") - .metadata; - existing.git_sha = None; - existing.git_branch = Some("sqlite-branch".to_string()); - existing.git_origin_url = None; - runtime - .upsert_thread(&existing) - .await - .expect("existing metadata upsert"); - - let mut config = crate::config::test_config(); - config.codex_home = codex_home.clone(); - config.model_provider_id = "test-provider".to_string(); - backfill_sessions(runtime.as_ref(), &config).await; - - let persisted = runtime - .get_thread(thread_id) - .await - .expect("get thread") - .expect("thread exists"); - assert_eq!(persisted.git_sha.as_deref(), Some("rollout-sha")); - assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch")); - assert_eq!( - persisted.git_origin_url.as_deref(), - Some("git@example.com:openai/codex.git") - ); - } - - #[tokio::test] - async fn backfill_sessions_normalizes_cwd_before_upsert() { - let dir = tempdir().expect("tempdir"); - let codex_home = dir.path().to_path_buf(); - let thread_uuid = Uuid::new_v4(); - let session_cwd = codex_home.join("."); - let rollout_path = write_rollout_in_sessions_with_cwd( - codex_home.as_path(), - "2026-01-27T12-34-56", - "2026-01-27T12:34:56Z", - thread_uuid, - session_cwd.clone(), - None, - ); - - let runtime = - codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string()) - .await - .expect("initialize runtime"); - - let mut config = crate::config::test_config(); - config.codex_home = codex_home.clone(); - config.model_provider_id = "test-provider".to_string(); - backfill_sessions(runtime.as_ref(), &config).await; - - let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); - let stored = runtime - .get_thread(thread_id) - .await - .expect("get thread") - .expect("thread should be backfilled"); - - assert_eq!(stored.rollout_path, rollout_path); - assert_eq!(stored.cwd, normalize_cwd_for_state_db(&session_cwd)); - } - - fn write_rollout_in_sessions( - codex_home: &Path, - filename_ts: &str, - event_ts: &str, - thread_uuid: Uuid, - git: Option, - ) -> PathBuf { - write_rollout_in_sessions_with_cwd( - codex_home, - filename_ts, - event_ts, - thread_uuid, - codex_home.to_path_buf(), - git, - ) - } - - fn write_rollout_in_sessions_with_cwd( - codex_home: &Path, - filename_ts: &str, - event_ts: &str, - thread_uuid: Uuid, - cwd: PathBuf, - git: Option, - ) -> PathBuf { - let id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); - let sessions_dir = codex_home.join("sessions"); - std::fs::create_dir_all(sessions_dir.as_path()).expect("create sessions dir"); - let path = sessions_dir.join(format!("rollout-{filename_ts}-{thread_uuid}.jsonl")); - let session_meta = SessionMeta { - id, - forked_from_id: None, - timestamp: event_ts.to_string(), - cwd, - originator: "cli".to_string(), - cli_version: "0.0.0".to_string(), - source: SessionSource::default(), - agent_nickname: None, - agent_role: None, - model_provider: Some("test-provider".to_string()), - base_instructions: None, - dynamic_tools: None, - memory_mode: None, - }; - let session_meta_line = SessionMetaLine { - meta: session_meta, - git, - }; - let rollout_line = RolloutLine { - timestamp: event_ts.to_string(), - item: RolloutItem::SessionMeta(session_meta_line), - }; - let json = serde_json::to_string(&rollout_line).expect("serialize rollout"); - let mut file = File::create(&path).expect("create rollout"); - writeln!(file, "{json}").expect("write rollout"); - path - } -} +#[path = "metadata_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/rollout/metadata_tests.rs b/codex-rs/core/src/rollout/metadata_tests.rs new file mode 100644 index 0000000000..5556d7002d --- /dev/null +++ b/codex-rs/core/src/rollout/metadata_tests.rs @@ -0,0 +1,377 @@ +use super::*; +use chrono::DateTime; +use chrono::NaiveDateTime; +use chrono::Timelike; +use chrono::Utc; +use codex_protocol::ThreadId; +use codex_protocol::protocol::CompactedItem; +use codex_protocol::protocol::GitInfo; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::SessionSource; +use codex_state::BackfillStatus; +use codex_state::ThreadMetadataBuilder; +use pretty_assertions::assert_eq; +use std::fs::File; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use tempfile::tempdir; +use uuid::Uuid; + +#[tokio::test] +async fn extract_metadata_from_rollout_uses_session_meta() { + let dir = tempdir().expect("tempdir"); + let uuid = Uuid::new_v4(); + let id = ThreadId::from_string(&uuid.to_string()).expect("thread id"); + let path = dir + .path() + .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); + + let session_meta = SessionMeta { + id, + forked_from_id: None, + timestamp: "2026-01-27T12:34:56Z".to_string(), + cwd: dir.path().to_path_buf(), + originator: "cli".to_string(), + cli_version: "0.0.0".to_string(), + source: SessionSource::default(), + agent_nickname: None, + agent_role: None, + model_provider: Some("openai".to_string()), + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }; + let session_meta_line = SessionMetaLine { + meta: session_meta, + git: None, + }; + let rollout_line = RolloutLine { + timestamp: "2026-01-27T12:34:56Z".to_string(), + item: RolloutItem::SessionMeta(session_meta_line.clone()), + }; + let json = serde_json::to_string(&rollout_line).expect("rollout json"); + let mut file = File::create(&path).expect("create rollout"); + writeln!(file, "{json}").expect("write rollout"); + + let outcome = extract_metadata_from_rollout(&path, "openai") + .await + .expect("extract"); + + let builder = builder_from_session_meta(&session_meta_line, path.as_path()).expect("builder"); + let mut expected = builder.build("openai"); + apply_rollout_item(&mut expected, &rollout_line.item, "openai"); + expected.updated_at = file_modified_time_utc(&path).await.expect("mtime"); + + assert_eq!(outcome.metadata, expected); + assert_eq!(outcome.memory_mode, None); + assert_eq!(outcome.parse_errors, 0); +} + +#[tokio::test] +async fn extract_metadata_from_rollout_returns_latest_memory_mode() { + let dir = tempdir().expect("tempdir"); + let uuid = Uuid::new_v4(); + let id = ThreadId::from_string(&uuid.to_string()).expect("thread id"); + let path = dir + .path() + .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); + + let session_meta = SessionMeta { + id, + forked_from_id: None, + timestamp: "2026-01-27T12:34:56Z".to_string(), + cwd: dir.path().to_path_buf(), + originator: "cli".to_string(), + cli_version: "0.0.0".to_string(), + source: SessionSource::default(), + agent_nickname: None, + agent_role: None, + model_provider: Some("openai".to_string()), + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }; + let polluted_meta = SessionMeta { + memory_mode: Some("polluted".to_string()), + ..session_meta.clone() + }; + let lines = vec![ + RolloutLine { + timestamp: "2026-01-27T12:34:56Z".to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }, + RolloutLine { + timestamp: "2026-01-27T12:35:00Z".to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: polluted_meta, + git: None, + }), + }, + ]; + let mut file = File::create(&path).expect("create rollout"); + for line in lines { + writeln!( + file, + "{}", + serde_json::to_string(&line).expect("serialize rollout line") + ) + .expect("write rollout line"); + } + + let outcome = extract_metadata_from_rollout(&path, "openai") + .await + .expect("extract"); + + assert_eq!(outcome.memory_mode.as_deref(), Some("polluted")); +} + +#[test] +fn builder_from_items_falls_back_to_filename() { + let dir = tempdir().expect("tempdir"); + let uuid = Uuid::new_v4(); + let path = dir + .path() + .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); + let items = vec![RolloutItem::Compacted(CompactedItem { + message: "noop".to_string(), + replacement_history: None, + })]; + + let builder = builder_from_items(items.as_slice(), path.as_path()).expect("builder"); + let naive = NaiveDateTime::parse_from_str("2026-01-27T12-34-56", "%Y-%m-%dT%H-%M-%S") + .expect("timestamp"); + let created_at = DateTime::::from_naive_utc_and_offset(naive, Utc) + .with_nanosecond(0) + .expect("nanosecond"); + let expected = ThreadMetadataBuilder::new( + ThreadId::from_string(&uuid.to_string()).expect("thread id"), + path, + created_at, + SessionSource::default(), + ); + + assert_eq!(builder, expected); +} + +#[tokio::test] +async fn backfill_sessions_resumes_from_watermark_and_marks_complete() { + let dir = tempdir().expect("tempdir"); + let codex_home = dir.path().to_path_buf(); + let first_uuid = Uuid::new_v4(); + let second_uuid = Uuid::new_v4(); + let first_path = write_rollout_in_sessions( + codex_home.as_path(), + "2026-01-27T12-34-56", + "2026-01-27T12:34:56Z", + first_uuid, + None, + ); + let second_path = write_rollout_in_sessions( + codex_home.as_path(), + "2026-01-27T12-35-56", + "2026-01-27T12:35:56Z", + second_uuid, + None, + ); + + let runtime = codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + let first_watermark = backfill_watermark_for_path(codex_home.as_path(), first_path.as_path()); + runtime.mark_backfill_running().await.expect("mark running"); + runtime + .checkpoint_backfill(first_watermark.as_str()) + .await + .expect("checkpoint first watermark"); + tokio::time::sleep(std::time::Duration::from_secs( + (BACKFILL_LEASE_SECONDS + 1) as u64, + )) + .await; + + let mut config = crate::config::test_config(); + config.codex_home = codex_home.clone(); + config.model_provider_id = "test-provider".to_string(); + backfill_sessions(runtime.as_ref(), &config).await; + + let first_id = ThreadId::from_string(&first_uuid.to_string()).expect("first thread id"); + let second_id = ThreadId::from_string(&second_uuid.to_string()).expect("second thread id"); + assert_eq!( + runtime + .get_thread(first_id) + .await + .expect("get first thread"), + None + ); + assert!( + runtime + .get_thread(second_id) + .await + .expect("get second thread") + .is_some() + ); + + let state = runtime + .get_backfill_state() + .await + .expect("get backfill state"); + assert_eq!(state.status, BackfillStatus::Complete); + assert_eq!( + state.last_watermark, + Some(backfill_watermark_for_path( + codex_home.as_path(), + second_path.as_path() + )) + ); + assert!(state.last_success_at.is_some()); +} + +#[tokio::test] +async fn backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_fields() { + let dir = tempdir().expect("tempdir"); + let codex_home = dir.path().to_path_buf(); + let thread_uuid = Uuid::new_v4(); + let rollout_path = write_rollout_in_sessions( + codex_home.as_path(), + "2026-01-27T12-34-56", + "2026-01-27T12:34:56Z", + thread_uuid, + Some(GitInfo { + commit_hash: Some("rollout-sha".to_string()), + branch: Some("rollout-branch".to_string()), + repository_url: Some("git@example.com:openai/codex.git".to_string()), + }), + ); + + let runtime = codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); + let mut existing = extract_metadata_from_rollout(&rollout_path, "test-provider") + .await + .expect("extract") + .metadata; + existing.git_sha = None; + existing.git_branch = Some("sqlite-branch".to_string()); + existing.git_origin_url = None; + runtime + .upsert_thread(&existing) + .await + .expect("existing metadata upsert"); + + let mut config = crate::config::test_config(); + config.codex_home = codex_home.clone(); + config.model_provider_id = "test-provider".to_string(); + backfill_sessions(runtime.as_ref(), &config).await; + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("get thread") + .expect("thread exists"); + assert_eq!(persisted.git_sha.as_deref(), Some("rollout-sha")); + assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch")); + assert_eq!( + persisted.git_origin_url.as_deref(), + Some("git@example.com:openai/codex.git") + ); +} + +#[tokio::test] +async fn backfill_sessions_normalizes_cwd_before_upsert() { + let dir = tempdir().expect("tempdir"); + let codex_home = dir.path().to_path_buf(); + let thread_uuid = Uuid::new_v4(); + let session_cwd = codex_home.join("."); + let rollout_path = write_rollout_in_sessions_with_cwd( + codex_home.as_path(), + "2026-01-27T12-34-56", + "2026-01-27T12:34:56Z", + thread_uuid, + session_cwd.clone(), + None, + ); + + let runtime = codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + + let mut config = crate::config::test_config(); + config.codex_home = codex_home.clone(); + config.model_provider_id = "test-provider".to_string(); + backfill_sessions(runtime.as_ref(), &config).await; + + let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); + let stored = runtime + .get_thread(thread_id) + .await + .expect("get thread") + .expect("thread should be backfilled"); + + assert_eq!(stored.rollout_path, rollout_path); + assert_eq!(stored.cwd, normalize_cwd_for_state_db(&session_cwd)); +} + +fn write_rollout_in_sessions( + codex_home: &Path, + filename_ts: &str, + event_ts: &str, + thread_uuid: Uuid, + git: Option, +) -> PathBuf { + write_rollout_in_sessions_with_cwd( + codex_home, + filename_ts, + event_ts, + thread_uuid, + codex_home.to_path_buf(), + git, + ) +} + +fn write_rollout_in_sessions_with_cwd( + codex_home: &Path, + filename_ts: &str, + event_ts: &str, + thread_uuid: Uuid, + cwd: PathBuf, + git: Option, +) -> PathBuf { + let id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); + let sessions_dir = codex_home.join("sessions"); + std::fs::create_dir_all(sessions_dir.as_path()).expect("create sessions dir"); + let path = sessions_dir.join(format!("rollout-{filename_ts}-{thread_uuid}.jsonl")); + let session_meta = SessionMeta { + id, + forked_from_id: None, + timestamp: event_ts.to_string(), + cwd, + originator: "cli".to_string(), + cli_version: "0.0.0".to_string(), + source: SessionSource::default(), + agent_nickname: None, + agent_role: None, + model_provider: Some("test-provider".to_string()), + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }; + let session_meta_line = SessionMetaLine { + meta: session_meta, + git, + }; + let rollout_line = RolloutLine { + timestamp: event_ts.to_string(), + item: RolloutItem::SessionMeta(session_meta_line), + }; + let json = serde_json::to_string(&rollout_line).expect("serialize rollout"); + let mut file = File::create(&path).expect("create rollout"); + writeln!(file, "{json}").expect("write rollout"); + path +} diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 89068d46f0..588e59ecc7 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -31,7 +31,9 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } @@ -49,7 +51,9 @@ pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) -> ResponseItem::Message { role, .. } => role != "developer", ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 851dcfb727..c4ec88784b 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -1102,517 +1102,5 @@ fn cwd_matches(session_cwd: &Path, cwd: &Path) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use crate::config::ConfigBuilder; - use crate::features::Feature; - use chrono::TimeZone; - use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; - use codex_protocol::protocol::AgentMessageEvent; - use codex_protocol::protocol::AskForApproval; - use codex_protocol::protocol::EventMsg; - use codex_protocol::protocol::SandboxPolicy; - use codex_protocol::protocol::TurnContextItem; - use codex_protocol::protocol::UserMessageEvent; - use pretty_assertions::assert_eq; - use std::fs::File; - use std::fs::{self}; - use std::io::Write; - use std::path::Path; - use std::path::PathBuf; - use std::time::Duration; - use tempfile::TempDir; - use uuid::Uuid; - - fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result { - let day_dir = root.join("sessions/2025/01/03"); - fs::create_dir_all(&day_dir)?; - let path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl")); - let mut file = File::create(&path)?; - let meta = serde_json::json!({ - "timestamp": ts, - "type": "session_meta", - "payload": { - "id": uuid, - "timestamp": ts, - "cwd": ".", - "originator": "test_originator", - "cli_version": "test_version", - "source": "cli", - "model_provider": "test-provider", - }, - }); - writeln!(file, "{meta}")?; - let user_event = serde_json::json!({ - "timestamp": ts, - "type": "event_msg", - "payload": { - "type": "user_message", - "message": "Hello from user", - "kind": "plain", - }, - }); - writeln!(file, "{user_event}")?; - Ok(path) - } - - #[tokio::test] - async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result<()> { - let home = TempDir::new().expect("temp dir"); - let config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - let thread_id = ThreadId::new(); - let recorder = RolloutRecorder::new( - &config, - RolloutRecorderParams::new( - thread_id, - None, - SessionSource::Exec, - BaseInstructions::default(), - Vec::new(), - EventPersistenceMode::Limited, - ), - None, - None, - ) - .await?; - - let rollout_path = recorder.rollout_path().to_path_buf(); - assert!( - !rollout_path.exists(), - "rollout file should not exist before first user message" - ); - - recorder - .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( - AgentMessageEvent { - message: "buffered-event".to_string(), - phase: None, - }, - ))]) - .await?; - recorder.flush().await?; - assert!( - !rollout_path.exists(), - "rollout file should remain deferred before first user message" - ); - - recorder - .record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( - UserMessageEvent { - message: "first-user-message".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }, - ))]) - .await?; - recorder.flush().await?; - assert!( - !rollout_path.exists(), - "user-message-like items should not materialize without explicit persist" - ); - - recorder.persist().await?; - // Second call verifies `persist()` is idempotent after materialization. - recorder.persist().await?; - assert!(rollout_path.exists(), "rollout file should be materialized"); - - let text = std::fs::read_to_string(&rollout_path)?; - assert!( - text.contains("\"type\":\"session_meta\""), - "expected session metadata in rollout" - ); - let buffered_idx = text - .find("buffered-event") - .expect("buffered event in rollout"); - let user_idx = text - .find("first-user-message") - .expect("first user message in rollout"); - assert!( - buffered_idx < user_idx, - "buffered items should preserve ordering" - ); - let text_after_second_persist = std::fs::read_to_string(&rollout_path)?; - assert_eq!(text_after_second_persist, text); - - recorder.shutdown().await?; - Ok(()) - } - - #[tokio::test] - async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> { - let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); - - let state_db = - StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) - .await - .expect("state db should initialize"); - state_db - .mark_backfill_complete(None) - .await - .expect("backfill should be complete"); - - let thread_id = ThreadId::new(); - let recorder = RolloutRecorder::new( - &config, - RolloutRecorderParams::new( - thread_id, - None, - SessionSource::Cli, - BaseInstructions::default(), - Vec::new(), - EventPersistenceMode::Limited, - ), - Some(state_db.clone()), - None, - ) - .await?; - - recorder - .record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( - UserMessageEvent { - message: "first-user-message".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }, - ))]) - .await?; - recorder.persist().await?; - recorder.flush().await?; - let initial_thread = state_db - .get_thread(thread_id) - .await - .expect("thread should load") - .expect("thread should exist"); - let initial_updated_at = initial_thread.updated_at; - let initial_title = initial_thread.title.clone(); - let initial_first_user_message = initial_thread.first_user_message.clone(); - - tokio::time::sleep(Duration::from_secs(1)).await; - - recorder - .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( - AgentMessageEvent { - message: "assistant text".to_string(), - phase: None, - }, - ))]) - .await?; - recorder.flush().await?; - - let updated_thread = state_db - .get_thread(thread_id) - .await - .expect("thread should load after agent message") - .expect("thread should still exist"); - - assert!(updated_thread.updated_at > initial_updated_at); - assert_eq!(updated_thread.title, initial_title); - assert_eq!( - updated_thread.first_user_message, - initial_first_user_message - ); - - recorder.shutdown().await?; - Ok(()) - } - - #[tokio::test] - async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() - -> std::io::Result<()> { - let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); - - let state_db = - StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) - .await - .expect("state db should initialize"); - let thread_id = ThreadId::new(); - let rollout_path = home.path().join("rollout.jsonl"); - let builder = ThreadMetadataBuilder::new( - thread_id, - rollout_path.clone(), - Utc::now(), - SessionSource::Cli, - ); - let items = vec![RolloutItem::EventMsg(EventMsg::AgentMessage( - AgentMessageEvent { - message: "assistant text".to_string(), - phase: None, - }, - ))]; - - sync_thread_state_after_write( - Some(state_db.as_ref()), - rollout_path.as_path(), - Some(&builder), - items.as_slice(), - config.model_provider_id.as_str(), - None, - ) - .await; - - let thread = state_db - .get_thread(thread_id) - .await - .expect("thread should load after fallback") - .expect("thread should be inserted after fallback"); - assert_eq!(thread.id, thread_id); - - Ok(()) - } - - #[tokio::test] - async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Result<()> { - let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .disable(Feature::Sqlite) - .expect("test config should allow sqlite to be disabled"); - - let newest = write_session_file(home.path(), "2025-01-03T12-00-00", Uuid::from_u128(9001))?; - let middle = write_session_file(home.path(), "2025-01-02T12-00-00", Uuid::from_u128(9002))?; - let _oldest = - write_session_file(home.path(), "2025-01-01T12-00-00", Uuid::from_u128(9003))?; - - let default_provider = config.model_provider_id.clone(); - let page1 = RolloutRecorder::list_threads( - &config, - 1, - None, - ThreadSortKey::CreatedAt, - &[], - None, - default_provider.as_str(), - None, - ) - .await?; - assert_eq!(page1.items.len(), 1); - assert_eq!(page1.items[0].path, newest); - let cursor = page1.next_cursor.clone().expect("cursor should be present"); - - let page2 = RolloutRecorder::list_threads( - &config, - 1, - Some(&cursor), - ThreadSortKey::CreatedAt, - &[], - None, - default_provider.as_str(), - None, - ) - .await?; - assert_eq!(page2.items.len(), 1); - assert_eq!(page2.items[0].path, middle); - Ok(()) - } - - #[tokio::test] - async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Result<()> { - let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); - - let uuid = Uuid::from_u128(9010); - let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); - let stale_path = home.path().join(format!( - "sessions/2099/01/01/rollout-2099-01-01T00-00-00-{uuid}.jsonl" - )); - - let runtime = codex_state::StateRuntime::init( - home.path().to_path_buf(), - config.model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - runtime - .mark_backfill_complete(None) - .await - .expect("backfill should be complete"); - let created_at = chrono::Utc - .with_ymd_and_hms(2025, 1, 3, 13, 0, 0) - .single() - .expect("valid datetime"); - let mut builder = codex_state::ThreadMetadataBuilder::new( - thread_id, - stale_path, - created_at, - SessionSource::Cli, - ); - builder.model_provider = Some(config.model_provider_id.clone()); - builder.cwd = home.path().to_path_buf(); - let mut metadata = builder.build(config.model_provider_id.as_str()); - metadata.first_user_message = Some("Hello from user".to_string()); - runtime - .upsert_thread(&metadata) - .await - .expect("state db upsert should succeed"); - - let default_provider = config.model_provider_id.clone(); - let page = RolloutRecorder::list_threads( - &config, - 10, - None, - ThreadSortKey::CreatedAt, - &[], - None, - default_provider.as_str(), - None, - ) - .await?; - assert_eq!(page.items.len(), 0); - let stored_path = runtime - .find_rollout_path_by_id(thread_id, Some(false)) - .await - .expect("state db lookup should succeed"); - assert_eq!(stored_path, None); - Ok(()) - } - - #[tokio::test] - async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Result<()> { - let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); - - let uuid = Uuid::from_u128(9011); - let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); - let real_path = write_session_file(home.path(), "2025-01-03T13-00-00", uuid)?; - let stale_path = home.path().join(format!( - "sessions/2099/01/01/rollout-2099-01-01T00-00-00-{uuid}.jsonl" - )); - - let runtime = codex_state::StateRuntime::init( - home.path().to_path_buf(), - config.model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - runtime - .mark_backfill_complete(None) - .await - .expect("backfill should be complete"); - let created_at = chrono::Utc - .with_ymd_and_hms(2025, 1, 3, 13, 0, 0) - .single() - .expect("valid datetime"); - let mut builder = codex_state::ThreadMetadataBuilder::new( - thread_id, - stale_path, - created_at, - SessionSource::Cli, - ); - builder.model_provider = Some(config.model_provider_id.clone()); - builder.cwd = home.path().to_path_buf(); - let mut metadata = builder.build(config.model_provider_id.as_str()); - metadata.first_user_message = Some("Hello from user".to_string()); - runtime - .upsert_thread(&metadata) - .await - .expect("state db upsert should succeed"); - - let default_provider = config.model_provider_id.clone(); - let page = RolloutRecorder::list_threads( - &config, - 1, - None, - ThreadSortKey::CreatedAt, - &[], - None, - default_provider.as_str(), - None, - ) - .await?; - assert_eq!(page.items.len(), 1); - assert_eq!(page.items[0].path, real_path); - - let repaired_path = runtime - .find_rollout_path_by_id(thread_id, Some(false)) - .await - .expect("state db lookup should succeed"); - assert_eq!(repaired_path, Some(real_path)); - Ok(()) - } - - #[tokio::test] - async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Result<()> { - let home = TempDir::new().expect("temp dir"); - let stale_cwd = home.path().join("stale"); - let latest_cwd = home.path().join("latest"); - fs::create_dir_all(&stale_cwd)?; - fs::create_dir_all(&latest_cwd)?; - - let path = write_session_file(home.path(), "2025-01-03T13-00-00", Uuid::from_u128(9012))?; - let mut file = std::fs::OpenOptions::new().append(true).open(&path)?; - let turn_context = RolloutLine { - timestamp: "2025-01-03T13:00:01Z".to_string(), - item: RolloutItem::TurnContext(TurnContextItem { - turn_id: Some("turn-1".to_string()), - trace_id: None, - cwd: latest_cwd.clone(), - current_date: None, - timezone: None, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - network: None, - model: "test-model".to_string(), - personality: None, - collaboration_mode: None, - realtime_active: None, - effort: None, - summary: ReasoningSummaryConfig::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, - }), - }; - writeln!(file, "{}", serde_json::to_string(&turn_context)?)?; - - assert!( - resume_candidate_matches_cwd( - path.as_path(), - Some(stale_cwd.as_path()), - latest_cwd.as_path(), - "test-provider", - ) - .await - ); - Ok(()) - } -} +#[path = "recorder_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/rollout/recorder_tests.rs b/codex-rs/core/src/rollout/recorder_tests.rs new file mode 100644 index 0000000000..f6f588574a --- /dev/null +++ b/codex-rs/core/src/rollout/recorder_tests.rs @@ -0,0 +1,509 @@ +use super::*; +use crate::config::ConfigBuilder; +use crate::features::Feature; +use chrono::TimeZone; +use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnContextItem; +use codex_protocol::protocol::UserMessageEvent; +use pretty_assertions::assert_eq; +use std::fs::File; +use std::fs::{self}; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use tempfile::TempDir; +use uuid::Uuid; + +fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result { + let day_dir = root.join("sessions/2025/01/03"); + fs::create_dir_all(&day_dir)?; + let path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl")); + let mut file = File::create(&path)?; + let meta = serde_json::json!({ + "timestamp": ts, + "type": "session_meta", + "payload": { + "id": uuid, + "timestamp": ts, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "cli", + "model_provider": "test-provider", + }, + }); + writeln!(file, "{meta}")?; + let user_event = serde_json::json!({ + "timestamp": ts, + "type": "event_msg", + "payload": { + "type": "user_message", + "message": "Hello from user", + "kind": "plain", + }, + }); + writeln!(file, "{user_event}")?; + Ok(path) +} + +#[tokio::test] +async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + let thread_id = ThreadId::new(); + let recorder = RolloutRecorder::new( + &config, + RolloutRecorderParams::new( + thread_id, + None, + SessionSource::Exec, + BaseInstructions::default(), + Vec::new(), + EventPersistenceMode::Limited, + ), + None, + None, + ) + .await?; + + let rollout_path = recorder.rollout_path().to_path_buf(); + assert!( + !rollout_path.exists(), + "rollout file should not exist before first user message" + ); + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( + AgentMessageEvent { + message: "buffered-event".to_string(), + phase: None, + }, + ))]) + .await?; + recorder.flush().await?; + assert!( + !rollout_path.exists(), + "rollout file should remain deferred before first user message" + ); + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "first-user-message".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + ))]) + .await?; + recorder.flush().await?; + assert!( + !rollout_path.exists(), + "user-message-like items should not materialize without explicit persist" + ); + + recorder.persist().await?; + // Second call verifies `persist()` is idempotent after materialization. + recorder.persist().await?; + assert!(rollout_path.exists(), "rollout file should be materialized"); + + let text = std::fs::read_to_string(&rollout_path)?; + assert!( + text.contains("\"type\":\"session_meta\""), + "expected session metadata in rollout" + ); + let buffered_idx = text + .find("buffered-event") + .expect("buffered event in rollout"); + let user_idx = text + .find("first-user-message") + .expect("first user message in rollout"); + assert!( + buffered_idx < user_idx, + "buffered items should preserve ordering" + ); + let text_after_second_persist = std::fs::read_to_string(&rollout_path)?; + assert_eq!(text_after_second_persist, text); + + recorder.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); + + let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) + .await + .expect("state db should initialize"); + state_db + .mark_backfill_complete(None) + .await + .expect("backfill should be complete"); + + let thread_id = ThreadId::new(); + let recorder = RolloutRecorder::new( + &config, + RolloutRecorderParams::new( + thread_id, + None, + SessionSource::Cli, + BaseInstructions::default(), + Vec::new(), + EventPersistenceMode::Limited, + ), + Some(state_db.clone()), + None, + ) + .await?; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "first-user-message".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + ))]) + .await?; + recorder.persist().await?; + recorder.flush().await?; + let initial_thread = state_db + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + let initial_updated_at = initial_thread.updated_at; + let initial_title = initial_thread.title.clone(); + let initial_first_user_message = initial_thread.first_user_message.clone(); + + tokio::time::sleep(Duration::from_secs(1)).await; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( + AgentMessageEvent { + message: "assistant text".to_string(), + phase: None, + }, + ))]) + .await?; + recorder.flush().await?; + + let updated_thread = state_db + .get_thread(thread_id) + .await + .expect("thread should load after agent message") + .expect("thread should still exist"); + + assert!(updated_thread.updated_at > initial_updated_at); + assert_eq!(updated_thread.title, initial_title); + assert_eq!( + updated_thread.first_user_message, + initial_first_user_message + ); + + recorder.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> std::io::Result<()> +{ + let home = TempDir::new().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); + + let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) + .await + .expect("state db should initialize"); + let thread_id = ThreadId::new(); + let rollout_path = home.path().join("rollout.jsonl"); + let builder = ThreadMetadataBuilder::new( + thread_id, + rollout_path.clone(), + Utc::now(), + SessionSource::Cli, + ); + let items = vec![RolloutItem::EventMsg(EventMsg::AgentMessage( + AgentMessageEvent { + message: "assistant text".to_string(), + phase: None, + }, + ))]; + + sync_thread_state_after_write( + Some(state_db.as_ref()), + rollout_path.as_path(), + Some(&builder), + items.as_slice(), + config.model_provider_id.as_str(), + None, + ) + .await; + + let thread = state_db + .get_thread(thread_id) + .await + .expect("thread should load after fallback") + .expect("thread should be inserted after fallback"); + assert_eq!(thread.id, thread_id); + + Ok(()) +} + +#[tokio::test] +async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + config + .features + .disable(Feature::Sqlite) + .expect("test config should allow sqlite to be disabled"); + + let newest = write_session_file(home.path(), "2025-01-03T12-00-00", Uuid::from_u128(9001))?; + let middle = write_session_file(home.path(), "2025-01-02T12-00-00", Uuid::from_u128(9002))?; + let _oldest = write_session_file(home.path(), "2025-01-01T12-00-00", Uuid::from_u128(9003))?; + + let default_provider = config.model_provider_id.clone(); + let page1 = RolloutRecorder::list_threads( + &config, + 1, + None, + ThreadSortKey::CreatedAt, + &[], + None, + default_provider.as_str(), + None, + ) + .await?; + assert_eq!(page1.items.len(), 1); + assert_eq!(page1.items[0].path, newest); + let cursor = page1.next_cursor.clone().expect("cursor should be present"); + + let page2 = RolloutRecorder::list_threads( + &config, + 1, + Some(&cursor), + ThreadSortKey::CreatedAt, + &[], + None, + default_provider.as_str(), + None, + ) + .await?; + assert_eq!(page2.items.len(), 1); + assert_eq!(page2.items[0].path, middle); + Ok(()) +} + +#[tokio::test] +async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); + + let uuid = Uuid::from_u128(9010); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let stale_path = home.path().join(format!( + "sessions/2099/01/01/rollout-2099-01-01T00-00-00-{uuid}.jsonl" + )); + + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + runtime + .mark_backfill_complete(None) + .await + .expect("backfill should be complete"); + let created_at = chrono::Utc + .with_ymd_and_hms(2025, 1, 3, 13, 0, 0) + .single() + .expect("valid datetime"); + let mut builder = codex_state::ThreadMetadataBuilder::new( + thread_id, + stale_path, + created_at, + SessionSource::Cli, + ); + builder.model_provider = Some(config.model_provider_id.clone()); + builder.cwd = home.path().to_path_buf(); + let mut metadata = builder.build(config.model_provider_id.as_str()); + metadata.first_user_message = Some("Hello from user".to_string()); + runtime + .upsert_thread(&metadata) + .await + .expect("state db upsert should succeed"); + + let default_provider = config.model_provider_id.clone(); + let page = RolloutRecorder::list_threads( + &config, + 10, + None, + ThreadSortKey::CreatedAt, + &[], + None, + default_provider.as_str(), + None, + ) + .await?; + assert_eq!(page.items.len(), 0); + let stored_path = runtime + .find_rollout_path_by_id(thread_id, Some(false)) + .await + .expect("state db lookup should succeed"); + assert_eq!(stored_path, None); + Ok(()) +} + +#[tokio::test] +async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); + + let uuid = Uuid::from_u128(9011); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let real_path = write_session_file(home.path(), "2025-01-03T13-00-00", uuid)?; + let stale_path = home.path().join(format!( + "sessions/2099/01/01/rollout-2099-01-01T00-00-00-{uuid}.jsonl" + )); + + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + runtime + .mark_backfill_complete(None) + .await + .expect("backfill should be complete"); + let created_at = chrono::Utc + .with_ymd_and_hms(2025, 1, 3, 13, 0, 0) + .single() + .expect("valid datetime"); + let mut builder = codex_state::ThreadMetadataBuilder::new( + thread_id, + stale_path, + created_at, + SessionSource::Cli, + ); + builder.model_provider = Some(config.model_provider_id.clone()); + builder.cwd = home.path().to_path_buf(); + let mut metadata = builder.build(config.model_provider_id.as_str()); + metadata.first_user_message = Some("Hello from user".to_string()); + runtime + .upsert_thread(&metadata) + .await + .expect("state db upsert should succeed"); + + let default_provider = config.model_provider_id.clone(); + let page = RolloutRecorder::list_threads( + &config, + 1, + None, + ThreadSortKey::CreatedAt, + &[], + None, + default_provider.as_str(), + None, + ) + .await?; + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].path, real_path); + + let repaired_path = runtime + .find_rollout_path_by_id(thread_id, Some(false)) + .await + .expect("state db lookup should succeed"); + assert_eq!(repaired_path, Some(real_path)); + Ok(()) +} + +#[tokio::test] +async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let stale_cwd = home.path().join("stale"); + let latest_cwd = home.path().join("latest"); + fs::create_dir_all(&stale_cwd)?; + fs::create_dir_all(&latest_cwd)?; + + let path = write_session_file(home.path(), "2025-01-03T13-00-00", Uuid::from_u128(9012))?; + let mut file = std::fs::OpenOptions::new().append(true).open(&path)?; + let turn_context = RolloutLine { + timestamp: "2025-01-03T13:00:01Z".to_string(), + item: RolloutItem::TurnContext(TurnContextItem { + turn_id: Some("turn-1".to_string()), + trace_id: None, + cwd: latest_cwd.clone(), + current_date: None, + timezone: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + network: None, + model: "test-model".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: None, + effort: None, + summary: ReasoningSummaryConfig::Auto, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + }), + }; + writeln!(file, "{}", serde_json::to_string(&turn_context)?)?; + + assert!( + resume_candidate_matches_cwd( + path.as_path(), + Some(stale_cwd.as_path()), + latest_cwd.as_path(), + "test-provider", + ) + .await + ); + Ok(()) +} diff --git a/codex-rs/core/src/rollout/session_index.rs b/codex-rs/core/src/rollout/session_index.rs index c546dca331..8c88dd39ad 100644 --- a/codex-rs/core/src/rollout/session_index.rs +++ b/codex-rs/core/src/rollout/session_index.rs @@ -229,172 +229,5 @@ where } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::collections::HashMap; - use std::collections::HashSet; - use tempfile::TempDir; - fn write_index(path: &Path, lines: &[SessionIndexEntry]) -> std::io::Result<()> { - let mut out = String::new(); - for entry in lines { - out.push_str(&serde_json::to_string(entry).unwrap()); - out.push('\n'); - } - std::fs::write(path, out) - } - - #[test] - fn find_thread_id_by_name_prefers_latest_entry() -> std::io::Result<()> { - let temp = TempDir::new()?; - let path = session_index_path(temp.path()); - let id1 = ThreadId::new(); - let id2 = ThreadId::new(); - let lines = vec![ - SessionIndexEntry { - id: id1, - thread_name: "same".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - SessionIndexEntry { - id: id2, - thread_name: "same".to_string(), - updated_at: "2024-01-02T00:00:00Z".to_string(), - }, - ]; - write_index(&path, &lines)?; - - let found = scan_index_from_end_by_name(&path, "same")?; - assert_eq!(found.map(|entry| entry.id), Some(id2)); - Ok(()) - } - - #[test] - fn find_thread_name_by_id_prefers_latest_entry() -> std::io::Result<()> { - let temp = TempDir::new()?; - let path = session_index_path(temp.path()); - let id = ThreadId::new(); - let lines = vec![ - SessionIndexEntry { - id, - thread_name: "first".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - SessionIndexEntry { - id, - thread_name: "second".to_string(), - updated_at: "2024-01-02T00:00:00Z".to_string(), - }, - ]; - write_index(&path, &lines)?; - - let found = scan_index_from_end_by_id(&path, &id)?; - assert_eq!( - found.map(|entry| entry.thread_name), - Some("second".to_string()) - ); - Ok(()) - } - - #[test] - fn scan_index_returns_none_when_entry_missing() -> std::io::Result<()> { - let temp = TempDir::new()?; - let path = session_index_path(temp.path()); - let id = ThreadId::new(); - let lines = vec![SessionIndexEntry { - id, - thread_name: "present".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - }]; - write_index(&path, &lines)?; - - let missing_name = scan_index_from_end_by_name(&path, "missing")?; - assert_eq!(missing_name, None); - - let missing_id = scan_index_from_end_by_id(&path, &ThreadId::new())?; - assert_eq!(missing_id, None); - Ok(()) - } - - #[tokio::test] - async fn find_thread_names_by_ids_prefers_latest_entry() -> std::io::Result<()> { - let temp = TempDir::new()?; - let path = session_index_path(temp.path()); - let id1 = ThreadId::new(); - let id2 = ThreadId::new(); - let lines = vec![ - SessionIndexEntry { - id: id1, - thread_name: "first".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - SessionIndexEntry { - id: id2, - thread_name: "other".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - SessionIndexEntry { - id: id1, - thread_name: "latest".to_string(), - updated_at: "2024-01-02T00:00:00Z".to_string(), - }, - ]; - write_index(&path, &lines)?; - - let mut ids = HashSet::new(); - ids.insert(id1); - ids.insert(id2); - - let mut expected = HashMap::new(); - expected.insert(id1, "latest".to_string()); - expected.insert(id2, "other".to_string()); - - let found = find_thread_names_by_ids(temp.path(), &ids).await?; - assert_eq!(found, expected); - Ok(()) - } - - #[test] - fn scan_index_finds_latest_match_among_mixed_entries() -> std::io::Result<()> { - let temp = TempDir::new()?; - let path = session_index_path(temp.path()); - let id_target = ThreadId::new(); - let id_other = ThreadId::new(); - let expected = SessionIndexEntry { - id: id_target, - thread_name: "target".to_string(), - updated_at: "2024-01-03T00:00:00Z".to_string(), - }; - let expected_other = SessionIndexEntry { - id: id_other, - thread_name: "target".to_string(), - updated_at: "2024-01-02T00:00:00Z".to_string(), - }; - // Resolution is based on append order (scan from end), not updated_at. - let lines = vec![ - SessionIndexEntry { - id: id_target, - thread_name: "target".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - expected_other.clone(), - expected.clone(), - SessionIndexEntry { - id: ThreadId::new(), - thread_name: "another".to_string(), - updated_at: "2024-01-04T00:00:00Z".to_string(), - }, - ]; - write_index(&path, &lines)?; - - let found_by_name = scan_index_from_end_by_name(&path, "target")?; - assert_eq!(found_by_name, Some(expected.clone())); - - let found_by_id = scan_index_from_end_by_id(&path, &id_target)?; - assert_eq!(found_by_id, Some(expected)); - - let found_other_by_id = scan_index_from_end_by_id(&path, &id_other)?; - assert_eq!(found_other_by_id, Some(expected_other)); - Ok(()) - } -} +#[path = "session_index_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/rollout/session_index_tests.rs b/codex-rs/core/src/rollout/session_index_tests.rs new file mode 100644 index 0000000000..864c4c5cf8 --- /dev/null +++ b/codex-rs/core/src/rollout/session_index_tests.rs @@ -0,0 +1,167 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::collections::HashSet; +use tempfile::TempDir; +fn write_index(path: &Path, lines: &[SessionIndexEntry]) -> std::io::Result<()> { + let mut out = String::new(); + for entry in lines { + out.push_str(&serde_json::to_string(entry).unwrap()); + out.push('\n'); + } + std::fs::write(path, out) +} + +#[test] +fn find_thread_id_by_name_prefers_latest_entry() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id1 = ThreadId::new(); + let id2 = ThreadId::new(); + let lines = vec![ + SessionIndexEntry { + id: id1, + thread_name: "same".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + SessionIndexEntry { + id: id2, + thread_name: "same".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + }, + ]; + write_index(&path, &lines)?; + + let found = scan_index_from_end_by_name(&path, "same")?; + assert_eq!(found.map(|entry| entry.id), Some(id2)); + Ok(()) +} + +#[test] +fn find_thread_name_by_id_prefers_latest_entry() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id = ThreadId::new(); + let lines = vec![ + SessionIndexEntry { + id, + thread_name: "first".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + SessionIndexEntry { + id, + thread_name: "second".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + }, + ]; + write_index(&path, &lines)?; + + let found = scan_index_from_end_by_id(&path, &id)?; + assert_eq!( + found.map(|entry| entry.thread_name), + Some("second".to_string()) + ); + Ok(()) +} + +#[test] +fn scan_index_returns_none_when_entry_missing() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id = ThreadId::new(); + let lines = vec![SessionIndexEntry { + id, + thread_name: "present".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }]; + write_index(&path, &lines)?; + + let missing_name = scan_index_from_end_by_name(&path, "missing")?; + assert_eq!(missing_name, None); + + let missing_id = scan_index_from_end_by_id(&path, &ThreadId::new())?; + assert_eq!(missing_id, None); + Ok(()) +} + +#[tokio::test] +async fn find_thread_names_by_ids_prefers_latest_entry() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id1 = ThreadId::new(); + let id2 = ThreadId::new(); + let lines = vec![ + SessionIndexEntry { + id: id1, + thread_name: "first".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + SessionIndexEntry { + id: id2, + thread_name: "other".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + SessionIndexEntry { + id: id1, + thread_name: "latest".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + }, + ]; + write_index(&path, &lines)?; + + let mut ids = HashSet::new(); + ids.insert(id1); + ids.insert(id2); + + let mut expected = HashMap::new(); + expected.insert(id1, "latest".to_string()); + expected.insert(id2, "other".to_string()); + + let found = find_thread_names_by_ids(temp.path(), &ids).await?; + assert_eq!(found, expected); + Ok(()) +} + +#[test] +fn scan_index_finds_latest_match_among_mixed_entries() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id_target = ThreadId::new(); + let id_other = ThreadId::new(); + let expected = SessionIndexEntry { + id: id_target, + thread_name: "target".to_string(), + updated_at: "2024-01-03T00:00:00Z".to_string(), + }; + let expected_other = SessionIndexEntry { + id: id_other, + thread_name: "target".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + }; + // Resolution is based on append order (scan from end), not updated_at. + let lines = vec![ + SessionIndexEntry { + id: id_target, + thread_name: "target".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + expected_other.clone(), + expected.clone(), + SessionIndexEntry { + id: ThreadId::new(), + thread_name: "another".to_string(), + updated_at: "2024-01-04T00:00:00Z".to_string(), + }, + ]; + write_index(&path, &lines)?; + + let found_by_name = scan_index_from_end_by_name(&path, "target")?; + assert_eq!(found_by_name, Some(expected.clone())); + + let found_by_id = scan_index_from_end_by_id(&path, &id_target)?; + assert_eq!(found_by_id, Some(expected)); + + let found_other_by_id = scan_index_from_end_by_id(&path, &id_other)?; + assert_eq!(found_other_by_id, Some(expected_other)); + Ok(()) +} diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/rollout/truncation.rs index 0b8081719d..490bf42b97 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/rollout/truncation.rs @@ -69,155 +69,5 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start( } #[cfg(test)] -mod tests { - use super::*; - use crate::codex::make_session_and_context; - use assert_matches::assert_matches; - use codex_protocol::models::ContentItem; - use codex_protocol::models::ReasoningItemReasoningSummary; - use codex_protocol::protocol::ThreadRolledBackEvent; - use pretty_assertions::assert_eq; - - fn user_msg(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::OutputText { - text: text.to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - } - } - - fn assistant_msg(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: text.to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - } - } - - #[test] - fn truncates_rollout_from_start_before_nth_user_only() { - let items = [ - user_msg("u1"), - assistant_msg("a1"), - assistant_msg("a2"), - user_msg("u2"), - assistant_msg("a3"), - ResponseItem::Reasoning { - id: "r1".to_string(), - summary: vec![ReasoningItemReasoningSummary::SummaryText { - text: "s".to_string(), - }], - content: None, - encrypted_content: None, - }, - ResponseItem::FunctionCall { - id: None, - name: "tool".to_string(), - arguments: "{}".to_string(), - call_id: "c1".to_string(), - }, - assistant_msg("a4"), - ]; - - let rollout: Vec = items - .iter() - .cloned() - .map(RolloutItem::ResponseItem) - .collect(); - - let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout, 1); - let expected = vec![ - RolloutItem::ResponseItem(items[0].clone()), - RolloutItem::ResponseItem(items[1].clone()), - RolloutItem::ResponseItem(items[2].clone()), - ]; - assert_eq!( - serde_json::to_value(&truncated).unwrap(), - serde_json::to_value(&expected).unwrap() - ); - - let truncated2 = truncate_rollout_before_nth_user_message_from_start(&rollout, 2); - assert_matches!(truncated2.as_slice(), []); - } - - #[test] - fn truncation_max_keeps_full_rollout() { - let rollout = vec![ - RolloutItem::ResponseItem(user_msg("u1")), - RolloutItem::ResponseItem(assistant_msg("a1")), - RolloutItem::ResponseItem(user_msg("u2")), - ]; - - let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout, usize::MAX); - - assert_eq!( - serde_json::to_value(&truncated).unwrap(), - serde_json::to_value(&rollout).unwrap() - ); - } - - #[test] - fn truncates_rollout_from_start_applies_thread_rollback_markers() { - let rollout_items = vec![ - RolloutItem::ResponseItem(user_msg("u1")), - RolloutItem::ResponseItem(assistant_msg("a1")), - RolloutItem::ResponseItem(user_msg("u2")), - RolloutItem::ResponseItem(assistant_msg("a2")), - RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent { - num_turns: 1, - })), - RolloutItem::ResponseItem(user_msg("u3")), - RolloutItem::ResponseItem(assistant_msg("a3")), - RolloutItem::ResponseItem(user_msg("u4")), - RolloutItem::ResponseItem(assistant_msg("a4")), - ]; - - // Effective user history after applying rollback(1) is: u1, u3, u4. - // So n_from_start=2 should cut before u4 (not u3). - let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout_items, 2); - let expected = rollout_items[..7].to_vec(); - assert_eq!( - serde_json::to_value(&truncated).unwrap(), - serde_json::to_value(&expected).unwrap() - ); - } - - #[tokio::test] - async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { - let (session, turn_context) = make_session_and_context().await; - let mut items = session.build_initial_context(&turn_context).await; - items.push(user_msg("feature request")); - items.push(assistant_msg("ack")); - items.push(user_msg("second question")); - items.push(assistant_msg("answer")); - - let rollout_items: Vec = items - .iter() - .cloned() - .map(RolloutItem::ResponseItem) - .collect(); - - let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout_items, 1); - let expected: Vec = vec![ - RolloutItem::ResponseItem(items[0].clone()), - RolloutItem::ResponseItem(items[1].clone()), - RolloutItem::ResponseItem(items[2].clone()), - RolloutItem::ResponseItem(items[3].clone()), - ]; - - assert_eq!( - serde_json::to_value(&truncated).unwrap(), - serde_json::to_value(&expected).unwrap() - ); - } -} +#[path = "truncation_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/rollout/truncation_tests.rs b/codex-rs/core/src/rollout/truncation_tests.rs new file mode 100644 index 0000000000..1f3e96a1a3 --- /dev/null +++ b/codex-rs/core/src/rollout/truncation_tests.rs @@ -0,0 +1,151 @@ +use super::*; +use crate::codex::make_session_and_context; +use assert_matches::assert_matches; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ReasoningItemReasoningSummary; +use codex_protocol::protocol::ThreadRolledBackEvent; +use pretty_assertions::assert_eq; + +fn user_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + } +} + +fn assistant_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + } +} + +#[test] +fn truncates_rollout_from_start_before_nth_user_only() { + let items = [ + user_msg("u1"), + assistant_msg("a1"), + assistant_msg("a2"), + user_msg("u2"), + assistant_msg("a3"), + ResponseItem::Reasoning { + id: "r1".to_string(), + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "s".to_string(), + }], + content: None, + encrypted_content: None, + }, + ResponseItem::FunctionCall { + id: None, + name: "tool".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "c1".to_string(), + }, + assistant_msg("a4"), + ]; + + let rollout: Vec = items + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect(); + + let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout, 1); + let expected = vec![ + RolloutItem::ResponseItem(items[0].clone()), + RolloutItem::ResponseItem(items[1].clone()), + RolloutItem::ResponseItem(items[2].clone()), + ]; + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&expected).unwrap() + ); + + let truncated2 = truncate_rollout_before_nth_user_message_from_start(&rollout, 2); + assert_matches!(truncated2.as_slice(), []); +} + +#[test] +fn truncation_max_keeps_full_rollout() { + let rollout = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::ResponseItem(user_msg("u2")), + ]; + + let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout, usize::MAX); + + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&rollout).unwrap() + ); +} + +#[test] +fn truncates_rollout_from_start_applies_thread_rollback_markers() { + let rollout_items = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::ResponseItem(user_msg("u2")), + RolloutItem::ResponseItem(assistant_msg("a2")), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + })), + RolloutItem::ResponseItem(user_msg("u3")), + RolloutItem::ResponseItem(assistant_msg("a3")), + RolloutItem::ResponseItem(user_msg("u4")), + RolloutItem::ResponseItem(assistant_msg("a4")), + ]; + + // Effective user history after applying rollback(1) is: u1, u3, u4. + // So n_from_start=2 should cut before u4 (not u3). + let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout_items, 2); + let expected = rollout_items[..7].to_vec(); + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&expected).unwrap() + ); +} + +#[tokio::test] +async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { + let (session, turn_context) = make_session_and_context().await; + let mut items = session.build_initial_context(&turn_context).await; + items.push(user_msg("feature request")); + items.push(assistant_msg("ack")); + items.push(user_msg("second question")); + items.push(assistant_msg("answer")); + + let rollout_items: Vec = items + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect(); + + let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout_items, 1); + let expected: Vec = vec![ + RolloutItem::ResponseItem(items[0].clone()), + RolloutItem::ResponseItem(items[1].clone()), + RolloutItem::ResponseItem(items[2].clone()), + RolloutItem::ResponseItem(items[3].clone()), + ]; + + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&expected).unwrap() + ); +} diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 6d97e7cd61..1fdd51a1b9 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -143,9 +143,6 @@ fn is_write_patch_constrained_to_writable_paths( Some(out) } - let unreadable_roots = file_system_sandbox_policy.get_unreadable_roots_with_cwd(cwd); - let writable_roots = file_system_sandbox_policy.get_writable_roots_with_cwd(cwd); - // Determine whether `path` is inside **any** writable root. Both `path` // and roots are converted to absolute, normalized forms before the // prefix check. @@ -156,20 +153,7 @@ fn is_write_patch_constrained_to_writable_paths( None => return false, }; - if unreadable_roots - .iter() - .any(|root| abs.starts_with(root.as_path())) - { - return false; - } - - if file_system_sandbox_policy.has_full_disk_write_access() { - return true; - } - - writable_roots - .iter() - .any(|writable_root| writable_root.is_path_writable(&abs)) + file_system_sandbox_policy.can_write_path_with_cwd(&abs, cwd) }; for (path, change) in action.changes() { @@ -196,259 +180,5 @@ fn is_write_patch_constrained_to_writable_paths( } #[cfg(test)] -mod tests { - use super::*; - use codex_protocol::protocol::FileSystemAccessMode; - use codex_protocol::protocol::FileSystemPath; - use codex_protocol::protocol::FileSystemSandboxEntry; - use codex_protocol::protocol::FileSystemSpecialPath; - use codex_protocol::protocol::RejectConfig; - use codex_utils_absolute_path::AbsolutePathBuf; - use pretty_assertions::assert_eq; - use tempfile::TempDir; - - #[test] - fn test_writable_roots_constraint() { - // Use a temporary directory as our workspace to avoid touching - // the real current working directory. - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path().to_path_buf(); - let parent = cwd.parent().unwrap().to_path_buf(); - - // Helper to build a single‑entry patch that adds a file at `p`. - let make_add_change = |p: PathBuf| ApplyPatchAction::new_add_for_test(&p, "".to_string()); - - let add_inside = make_add_change(cwd.join("inner.txt")); - let add_outside = make_add_change(parent.join("outside.txt")); - - // Policy limited to the workspace only; exclude system temp roots so - // only `cwd` is writable by default. - let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - - assert!(is_write_patch_constrained_to_writable_paths( - &add_inside, - &FileSystemSandboxPolicy::from(&policy_workspace_only), - &cwd, - )); - - assert!(!is_write_patch_constrained_to_writable_paths( - &add_outside, - &FileSystemSandboxPolicy::from(&policy_workspace_only), - &cwd, - )); - - // With the parent dir explicitly added as a writable root, the - // outside write should be permitted. - let policy_with_parent = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from(parent).unwrap()], - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - assert!(is_write_patch_constrained_to_writable_paths( - &add_outside, - &FileSystemSandboxPolicy::from(&policy_with_parent), - &cwd, - )); - } - - #[test] - fn external_sandbox_auto_approves_in_on_request() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path().to_path_buf(); - let add_inside = ApplyPatchAction::new_add_for_test(&cwd.join("inner.txt"), "".to_string()); - - let policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Enabled, - }; - - assert_eq!( - assess_patch_safety( - &add_inside, - AskForApproval::OnRequest, - &policy, - &FileSystemSandboxPolicy::from(&policy), - &cwd, - WindowsSandboxLevel::Disabled - ), - SafetyCheck::AutoApprove { - sandbox_type: SandboxType::None, - user_explicitly_approved: false, - } - ); - } - - #[test] - fn reject_with_all_flags_false_matches_on_request_for_out_of_root_patch() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path().to_path_buf(); - let parent = cwd.parent().unwrap().to_path_buf(); - let add_outside = - ApplyPatchAction::new_add_for_test(&parent.join("outside.txt"), "".to_string()); - let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - - assert_eq!( - assess_patch_safety( - &add_outside, - AskForApproval::OnRequest, - &policy_workspace_only, - &FileSystemSandboxPolicy::from(&policy_workspace_only), - &cwd, - WindowsSandboxLevel::Disabled, - ), - SafetyCheck::AskUser, - ); - assert_eq!( - assess_patch_safety( - &add_outside, - AskForApproval::Reject(RejectConfig { - sandbox_approval: false, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - }), - &policy_workspace_only, - &FileSystemSandboxPolicy::from(&policy_workspace_only), - &cwd, - WindowsSandboxLevel::Disabled, - ), - SafetyCheck::AskUser, - ); - } - - #[test] - fn reject_sandbox_approval_rejects_out_of_root_patch() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path().to_path_buf(); - let parent = cwd.parent().unwrap().to_path_buf(); - let add_outside = - ApplyPatchAction::new_add_for_test(&parent.join("outside.txt"), "".to_string()); - let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - - assert_eq!( - assess_patch_safety( - &add_outside, - AskForApproval::Reject(RejectConfig { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - }), - &policy_workspace_only, - &FileSystemSandboxPolicy::from(&policy_workspace_only), - &cwd, - WindowsSandboxLevel::Disabled, - ), - SafetyCheck::Reject { - reason: "writing outside of the project; rejected by user approval settings" - .to_string(), - }, - ); - } - #[test] - fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path().to_path_buf(); - let blocked_path = cwd.join("blocked.txt"); - let blocked_absolute = AbsolutePathBuf::from_absolute_path(blocked_path.clone()).unwrap(); - let action = ApplyPatchAction::new_add_for_test(&blocked_path, "".to_string()); - let sandbox_policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, - }; - let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Write, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: blocked_absolute, - }, - access: FileSystemAccessMode::None, - }, - ]); - - assert!(!is_write_patch_constrained_to_writable_paths( - &action, - &file_system_sandbox_policy, - &cwd, - )); - assert_eq!( - assess_patch_safety( - &action, - AskForApproval::OnRequest, - &sandbox_policy, - &file_system_sandbox_policy, - &cwd, - WindowsSandboxLevel::Disabled, - ), - SafetyCheck::AskUser, - ); - } - - #[test] - fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path().to_path_buf(); - let blocked_path = cwd.join("docs").join("blocked.txt"); - let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd).unwrap(); - let action = ApplyPatchAction::new_add_for_test(&blocked_path, "".to_string()); - let sandbox_policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, - }; - let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::CurrentWorkingDirectory, - }, - access: FileSystemAccessMode::Write, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: docs_absolute, - }, - access: FileSystemAccessMode::Read, - }, - ]); - - assert!(!is_write_patch_constrained_to_writable_paths( - &action, - &file_system_sandbox_policy, - &cwd, - )); - assert_eq!( - assess_patch_safety( - &action, - AskForApproval::OnRequest, - &sandbox_policy, - &file_system_sandbox_policy, - &cwd, - WindowsSandboxLevel::Disabled, - ), - SafetyCheck::AskUser, - ); - } -} +#[path = "safety_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/safety_tests.rs b/codex-rs/core/src/safety_tests.rs new file mode 100644 index 0000000000..555d557d3c --- /dev/null +++ b/codex-rs/core/src/safety_tests.rs @@ -0,0 +1,254 @@ +use super::*; +use codex_protocol::protocol::FileSystemAccessMode; +use codex_protocol::protocol::FileSystemPath; +use codex_protocol::protocol::FileSystemSandboxEntry; +use codex_protocol::protocol::FileSystemSpecialPath; +use codex_protocol::protocol::RejectConfig; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[test] +fn test_writable_roots_constraint() { + // Use a temporary directory as our workspace to avoid touching + // the real current working directory. + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path().to_path_buf(); + let parent = cwd.parent().unwrap().to_path_buf(); + + // Helper to build a single‑entry patch that adds a file at `p`. + let make_add_change = |p: PathBuf| ApplyPatchAction::new_add_for_test(&p, "".to_string()); + + let add_inside = make_add_change(cwd.join("inner.txt")); + let add_outside = make_add_change(parent.join("outside.txt")); + + // Policy limited to the workspace only; exclude system temp roots so + // only `cwd` is writable by default. + let policy_workspace_only = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + assert!(is_write_patch_constrained_to_writable_paths( + &add_inside, + &FileSystemSandboxPolicy::from(&policy_workspace_only), + &cwd, + )); + + assert!(!is_write_patch_constrained_to_writable_paths( + &add_outside, + &FileSystemSandboxPolicy::from(&policy_workspace_only), + &cwd, + )); + + // With the parent dir explicitly added as a writable root, the + // outside write should be permitted. + let policy_with_parent = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::try_from(parent).unwrap()], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + assert!(is_write_patch_constrained_to_writable_paths( + &add_outside, + &FileSystemSandboxPolicy::from(&policy_with_parent), + &cwd, + )); +} + +#[test] +fn external_sandbox_auto_approves_in_on_request() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path().to_path_buf(); + let add_inside = ApplyPatchAction::new_add_for_test(&cwd.join("inner.txt"), "".to_string()); + + let policy = SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Enabled, + }; + + assert_eq!( + assess_patch_safety( + &add_inside, + AskForApproval::OnRequest, + &policy, + &FileSystemSandboxPolicy::from(&policy), + &cwd, + WindowsSandboxLevel::Disabled + ), + SafetyCheck::AutoApprove { + sandbox_type: SandboxType::None, + user_explicitly_approved: false, + } + ); +} + +#[test] +fn reject_with_all_flags_false_matches_on_request_for_out_of_root_patch() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path().to_path_buf(); + let parent = cwd.parent().unwrap().to_path_buf(); + let add_outside = + ApplyPatchAction::new_add_for_test(&parent.join("outside.txt"), "".to_string()); + let policy_workspace_only = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + assert_eq!( + assess_patch_safety( + &add_outside, + AskForApproval::OnRequest, + &policy_workspace_only, + &FileSystemSandboxPolicy::from(&policy_workspace_only), + &cwd, + WindowsSandboxLevel::Disabled, + ), + SafetyCheck::AskUser, + ); + assert_eq!( + assess_patch_safety( + &add_outside, + AskForApproval::Reject(RejectConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }), + &policy_workspace_only, + &FileSystemSandboxPolicy::from(&policy_workspace_only), + &cwd, + WindowsSandboxLevel::Disabled, + ), + SafetyCheck::AskUser, + ); +} + +#[test] +fn reject_sandbox_approval_rejects_out_of_root_patch() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path().to_path_buf(); + let parent = cwd.parent().unwrap().to_path_buf(); + let add_outside = + ApplyPatchAction::new_add_for_test(&parent.join("outside.txt"), "".to_string()); + let policy_workspace_only = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + assert_eq!( + assess_patch_safety( + &add_outside, + AskForApproval::Reject(RejectConfig { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }), + &policy_workspace_only, + &FileSystemSandboxPolicy::from(&policy_workspace_only), + &cwd, + WindowsSandboxLevel::Disabled, + ), + SafetyCheck::Reject { + reason: "writing outside of the project; rejected by user approval settings" + .to_string(), + }, + ); +} +#[test] +fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path().to_path_buf(); + let blocked_path = cwd.join("blocked.txt"); + let blocked_absolute = AbsolutePathBuf::from_absolute_path(blocked_path.clone()).unwrap(); + let action = ApplyPatchAction::new_add_for_test(&blocked_path, "".to_string()); + let sandbox_policy = SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Restricted, + }; + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: blocked_absolute, + }, + access: FileSystemAccessMode::None, + }, + ]); + + assert!(!is_write_patch_constrained_to_writable_paths( + &action, + &file_system_sandbox_policy, + &cwd, + )); + assert_eq!( + assess_patch_safety( + &action, + AskForApproval::OnRequest, + &sandbox_policy, + &file_system_sandbox_policy, + &cwd, + WindowsSandboxLevel::Disabled, + ), + SafetyCheck::AskUser, + ); +} + +#[test] +fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path().to_path_buf(); + let blocked_path = cwd.join("docs").join("blocked.txt"); + let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd).unwrap(); + let action = ApplyPatchAction::new_add_for_test(&blocked_path, "".to_string()); + let sandbox_policy = SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Restricted, + }; + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: docs_absolute, + }, + access: FileSystemAccessMode::Read, + }, + ]); + + assert!(!is_write_patch_constrained_to_writable_paths( + &action, + &file_system_sandbox_policy, + &cwd, + )); + assert_eq!( + assess_patch_safety( + &action, + AskForApproval::OnRequest, + &sandbox_policy, + &file_system_sandbox_policy, + &cwd, + WindowsSandboxLevel::Disabled, + ), + SafetyCheck::AskUser, + ); +} diff --git a/codex-rs/core/src/sandbox_tags.rs b/codex-rs/core/src/sandbox_tags.rs index 56c9e0f184..6a66d17dd0 100644 --- a/codex-rs/core/src/sandbox_tags.rs +++ b/codex-rs/core/src/sandbox_tags.rs @@ -6,7 +6,6 @@ use codex_protocol::config_types::WindowsSandboxLevel; pub(crate) fn sandbox_tag( policy: &SandboxPolicy, windows_sandbox_level: WindowsSandboxLevel, - use_linux_sandbox_bwrap: bool, ) -> &'static str { if matches!(policy, SandboxPolicy::DangerFullAccess) { return "none"; @@ -18,9 +17,6 @@ pub(crate) fn sandbox_tag( { return "windows_elevated"; } - if cfg!(target_os = "linux") && use_linux_sandbox_bwrap { - return "linux_bubblewrap"; - } get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) .map(SandboxType::as_metric_tag) @@ -28,51 +24,5 @@ pub(crate) fn sandbox_tag( } #[cfg(test)] -mod tests { - use super::sandbox_tag; - use crate::exec::SandboxType; - use crate::protocol::SandboxPolicy; - use crate::safety::get_platform_sandbox; - use codex_protocol::config_types::WindowsSandboxLevel; - use codex_protocol::protocol::NetworkAccess; - use pretty_assertions::assert_eq; - - #[test] - fn danger_full_access_is_untagged_even_when_bubblewrap_is_enabled() { - let actual = sandbox_tag( - &SandboxPolicy::DangerFullAccess, - WindowsSandboxLevel::Disabled, - true, - ); - assert_eq!(actual, "none"); - } - - #[test] - fn external_sandbox_keeps_external_tag_when_bubblewrap_is_enabled() { - let actual = sandbox_tag( - &SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - }, - WindowsSandboxLevel::Disabled, - true, - ); - assert_eq!(actual, "external"); - } - - #[test] - fn bubblewrap_feature_sets_distinct_linux_tag() { - let actual = sandbox_tag( - &SandboxPolicy::new_read_only_policy(), - WindowsSandboxLevel::Disabled, - true, - ); - let expected = if cfg!(target_os = "linux") { - "linux_bubblewrap" - } else { - get_platform_sandbox(false) - .map(SandboxType::as_metric_tag) - .unwrap_or("none") - }; - assert_eq!(actual, expected); - } -} +#[path = "sandbox_tags_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/sandbox_tags_tests.rs b/codex-rs/core/src/sandbox_tags_tests.rs new file mode 100644 index 0000000000..7084d5ff92 --- /dev/null +++ b/codex-rs/core/src/sandbox_tags_tests.rs @@ -0,0 +1,39 @@ +use super::sandbox_tag; +use crate::exec::SandboxType; +use crate::protocol::SandboxPolicy; +use crate::safety::get_platform_sandbox; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::protocol::NetworkAccess; +use pretty_assertions::assert_eq; + +#[test] +fn danger_full_access_is_untagged_even_when_linux_sandbox_defaults_apply() { + let actual = sandbox_tag( + &SandboxPolicy::DangerFullAccess, + WindowsSandboxLevel::Disabled, + ); + assert_eq!(actual, "none"); +} + +#[test] +fn external_sandbox_keeps_external_tag_when_linux_sandbox_defaults_apply() { + let actual = sandbox_tag( + &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }, + WindowsSandboxLevel::Disabled, + ); + assert_eq!(actual, "external"); +} + +#[test] +fn default_linux_sandbox_uses_platform_sandbox_tag() { + let actual = sandbox_tag( + &SandboxPolicy::new_read_only_policy(), + WindowsSandboxLevel::Disabled, + ); + let expected = get_platform_sandbox(false) + .map(SandboxType::as_metric_tag) + .unwrap_or("none"); + assert_eq!(actual, expected); +} diff --git a/codex-rs/core/src/sandboxing/macos_permissions.rs b/codex-rs/core/src/sandboxing/macos_permissions.rs index 5717a558cf..1a409d4bdf 100644 --- a/codex-rs/core/src/sandboxing/macos_permissions.rs +++ b/codex-rs/core/src/sandboxing/macos_permissions.rs @@ -150,129 +150,5 @@ fn intersect_macos_automation_permission( } #[cfg(all(test, target_os = "macos"))] -mod tests { - use super::intersect_macos_automation_permission; - use super::intersect_macos_seatbelt_profile_extensions; - use super::merge_macos_seatbelt_profile_extensions; - use super::union_macos_automation_permission; - use super::union_macos_contacts_permission; - use super::union_macos_preferences_permission; - use codex_protocol::models::MacOsAutomationPermission; - use codex_protocol::models::MacOsContactsPermission; - use codex_protocol::models::MacOsPreferencesPermission; - use codex_protocol::models::MacOsSeatbeltProfileExtensions; - use pretty_assertions::assert_eq; - - #[test] - fn merge_extensions_widens_permissions() { - let base = MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Calendar".to_string(), - ]), - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::ReadOnly, - }; - let requested = MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - "com.apple.Calendar".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::ReadWrite, - }; - - let merged = - merge_macos_seatbelt_profile_extensions(Some(&base), Some(&requested)).expect("merge"); - - assert_eq!( - merged, - MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Calendar".to_string(), - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::ReadWrite, - } - ); - } - - #[test] - fn union_macos_preferences_permission_does_not_downgrade() { - let base = MacOsPreferencesPermission::ReadWrite; - let requested = MacOsPreferencesPermission::ReadOnly; - - let merged = union_macos_preferences_permission(&base, &requested); - - assert_eq!(merged, MacOsPreferencesPermission::ReadWrite); - } - - #[test] - fn union_macos_automation_permission_all_is_dominant() { - let base = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]); - let requested = MacOsAutomationPermission::All; - - let merged = union_macos_automation_permission(&base, &requested); - - assert_eq!(merged, MacOsAutomationPermission::All); - } - - #[test] - fn intersect_macos_automation_permission_keeps_common_bundle_ids() { - let requested = MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - "com.apple.Calendar".to_string(), - ]); - let granted = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]); - - let intersected = intersect_macos_automation_permission(&requested, &granted); - - assert_eq!( - intersected, - MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]) - ); - } - - #[test] - fn intersect_macos_seatbelt_profile_extensions_preserves_default_grant() { - let requested = MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: false, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }; - let granted = MacOsSeatbeltProfileExtensions::default(); - - let intersected = - intersect_macos_seatbelt_profile_extensions(Some(requested), Some(granted)); - - assert_eq!(intersected, Some(MacOsSeatbeltProfileExtensions::default())); - } - - #[test] - fn union_macos_contacts_permission_does_not_downgrade() { - let base = MacOsContactsPermission::ReadWrite; - let requested = MacOsContactsPermission::ReadOnly; - - let merged = union_macos_contacts_permission(&base, &requested); - - assert_eq!(merged, MacOsContactsPermission::ReadWrite); - } -} +#[path = "macos_permissions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/sandboxing/macos_permissions_tests.rs b/codex-rs/core/src/sandboxing/macos_permissions_tests.rs new file mode 100644 index 0000000000..97a2a2c753 --- /dev/null +++ b/codex-rs/core/src/sandboxing/macos_permissions_tests.rs @@ -0,0 +1,121 @@ +use super::intersect_macos_automation_permission; +use super::intersect_macos_seatbelt_profile_extensions; +use super::merge_macos_seatbelt_profile_extensions; +use super::union_macos_automation_permission; +use super::union_macos_contacts_permission; +use super::union_macos_preferences_permission; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsContactsPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use pretty_assertions::assert_eq; + +#[test] +fn merge_extensions_widens_permissions() { + let base = MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadOnly, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Calendar".to_string(), + ]), + macos_launch_services: false, + macos_accessibility: false, + macos_calendar: false, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::ReadOnly, + }; + let requested = MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + "com.apple.Calendar".to_string(), + ]), + macos_launch_services: true, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: true, + macos_contacts: MacOsContactsPermission::ReadWrite, + }; + + let merged = + merge_macos_seatbelt_profile_extensions(Some(&base), Some(&requested)).expect("merge"); + + assert_eq!( + merged, + MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Calendar".to_string(), + "com.apple.Notes".to_string(), + ]), + macos_launch_services: true, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: true, + macos_contacts: MacOsContactsPermission::ReadWrite, + } + ); +} + +#[test] +fn union_macos_preferences_permission_does_not_downgrade() { + let base = MacOsPreferencesPermission::ReadWrite; + let requested = MacOsPreferencesPermission::ReadOnly; + + let merged = union_macos_preferences_permission(&base, &requested); + + assert_eq!(merged, MacOsPreferencesPermission::ReadWrite); +} + +#[test] +fn union_macos_automation_permission_all_is_dominant() { + let base = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]); + let requested = MacOsAutomationPermission::All; + + let merged = union_macos_automation_permission(&base, &requested); + + assert_eq!(merged, MacOsAutomationPermission::All); +} + +#[test] +fn intersect_macos_automation_permission_keeps_common_bundle_ids() { + let requested = MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + "com.apple.Calendar".to_string(), + ]); + let granted = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]); + + let intersected = intersect_macos_automation_permission(&requested, &granted); + + assert_eq!( + intersected, + MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]) + ); +} + +#[test] +fn intersect_macos_seatbelt_profile_extensions_preserves_default_grant() { + let requested = MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]), + macos_launch_services: false, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::None, + }; + let granted = MacOsSeatbeltProfileExtensions::default(); + + let intersected = intersect_macos_seatbelt_profile_extensions(Some(requested), Some(granted)); + + assert_eq!(intersected, Some(MacOsSeatbeltProfileExtensions::default())); +} + +#[test] +fn union_macos_contacts_permission_does_not_downgrade() { + let base = MacOsContactsPermission::ReadWrite; + let requested = MacOsContactsPermission::ReadOnly; + + let merged = union_macos_contacts_permission(&base, &requested); + + assert_eq!(merged, MacOsContactsPermission::ReadWrite); +} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 377ecb3db8..fe4918a266 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -94,7 +94,7 @@ pub(crate) struct SandboxTransformRequest<'a> { #[cfg(target_os = "macos")] pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>, pub codex_linux_sandbox_exe: Option<&'a PathBuf>, - pub use_linux_sandbox_bwrap: bool, + pub use_legacy_landlock: bool, pub windows_sandbox_level: WindowsSandboxLevel, } @@ -388,6 +388,26 @@ fn merge_file_system_policy_with_additional_permissions( } } +pub(crate) fn effective_file_system_sandbox_policy( + file_system_policy: &FileSystemSandboxPolicy, + additional_permissions: Option<&PermissionProfile>, +) -> FileSystemSandboxPolicy { + let Some(additional_permissions) = additional_permissions else { + return file_system_policy.clone(); + }; + + let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions); + if extra_reads.is_empty() && extra_writes.is_empty() { + file_system_policy.clone() + } else { + merge_file_system_policy_with_additional_permissions( + file_system_policy, + extra_reads, + extra_writes, + ) + } +} + fn merge_read_only_access_with_additional_reads( read_only_access: &ReadOnlyAccess, extra_reads: Vec, @@ -571,7 +591,7 @@ impl SandboxManager { #[cfg(target_os = "macos")] macos_seatbelt_profile_extensions, codex_linux_sandbox_exe, - use_linux_sandbox_bwrap, + use_legacy_landlock, windows_sandbox_level, } = request; #[cfg(not(target_os = "macos"))] @@ -653,7 +673,7 @@ impl SandboxManager { &effective_file_system_policy, effective_network_policy, sandbox_policy_cwd, - use_linux_sandbox_bwrap, + use_legacy_landlock, allow_proxy_network, ); let mut full_command = Vec::with_capacity(1 + args.len()); @@ -717,651 +737,5 @@ pub async fn execute_exec_request_with_after_spawn( } #[cfg(test)] -mod tests { - #[cfg(target_os = "macos")] - use super::EffectiveSandboxPermissions; - use super::SandboxManager; - #[cfg(target_os = "macos")] - use super::intersect_permission_profiles; - use super::merge_file_system_policy_with_additional_permissions; - use super::normalize_additional_permissions; - use super::sandbox_policy_with_additional_permissions; - use super::should_require_platform_sandbox; - use crate::exec::SandboxType; - use crate::protocol::NetworkAccess; - use crate::protocol::ReadOnlyAccess; - use crate::protocol::SandboxPolicy; - use crate::tools::sandboxing::SandboxablePreference; - use codex_protocol::config_types::WindowsSandboxLevel; - use codex_protocol::models::FileSystemPermissions; - #[cfg(target_os = "macos")] - use codex_protocol::models::MacOsAutomationPermission; - #[cfg(target_os = "macos")] - use codex_protocol::models::MacOsContactsPermission; - #[cfg(target_os = "macos")] - use codex_protocol::models::MacOsPreferencesPermission; - #[cfg(target_os = "macos")] - use codex_protocol::models::MacOsSeatbeltProfileExtensions; - use codex_protocol::models::NetworkPermissions; - use codex_protocol::models::PermissionProfile; - use codex_protocol::permissions::FileSystemAccessMode; - use codex_protocol::permissions::FileSystemPath; - use codex_protocol::permissions::FileSystemSandboxEntry; - use codex_protocol::permissions::FileSystemSandboxPolicy; - use codex_protocol::permissions::FileSystemSpecialPath; - use codex_protocol::permissions::NetworkSandboxPolicy; - use codex_utils_absolute_path::AbsolutePathBuf; - use dunce::canonicalize; - use pretty_assertions::assert_eq; - use std::collections::HashMap; - use tempfile::TempDir; - - #[test] - fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() { - let manager = SandboxManager::new(); - let sandbox = manager.select_initial( - &FileSystemSandboxPolicy::unrestricted(), - NetworkSandboxPolicy::Enabled, - SandboxablePreference::Auto, - WindowsSandboxLevel::Disabled, - false, - ); - assert_eq!(sandbox, SandboxType::None); - } - - #[test] - fn danger_full_access_uses_platform_sandbox_with_network_requirements() { - let manager = SandboxManager::new(); - let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); - let sandbox = manager.select_initial( - &FileSystemSandboxPolicy::unrestricted(), - NetworkSandboxPolicy::Enabled, - SandboxablePreference::Auto, - WindowsSandboxLevel::Disabled, - true, - ); - assert_eq!(sandbox, expected); - } - - #[test] - fn restricted_file_system_uses_platform_sandbox_without_managed_network() { - let manager = SandboxManager::new(); - let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); - let sandbox = manager.select_initial( - &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }]), - NetworkSandboxPolicy::Enabled, - SandboxablePreference::Auto, - WindowsSandboxLevel::Disabled, - false, - ); - assert_eq!(sandbox, expected); - } - - #[test] - fn full_access_restricted_policy_skips_platform_sandbox_when_network_is_enabled() { - let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Write, - }]); - - assert_eq!( - should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false), - false - ); - } - - #[test] - fn root_write_policy_with_carveouts_still_uses_platform_sandbox() { - let blocked = AbsolutePathBuf::resolve_path_against_base( - "blocked", - std::env::current_dir().expect("current dir"), - ) - .expect("blocked path"); - let policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Write, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { path: blocked }, - access: FileSystemAccessMode::None, - }, - ]); - - assert_eq!( - should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false), - true - ); - } - - #[test] - fn full_access_restricted_policy_still_uses_platform_sandbox_for_restricted_network() { - let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Write, - }]); - - assert_eq!( - should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Restricted, false), - true - ); - } - - #[test] - fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() { - let manager = SandboxManager::new(); - let cwd = std::env::current_dir().expect("current dir"); - let exec_request = manager - .transform(super::SandboxTransformRequest { - spec: super::CommandSpec { - program: "true".to_string(), - args: Vec::new(), - cwd: cwd.clone(), - env: HashMap::new(), - expiration: crate::exec::ExecExpiration::DefaultTimeout, - sandbox_permissions: super::SandboxPermissions::UseDefault, - additional_permissions: None, - justification: None, - }, - policy: &SandboxPolicy::ExternalSandbox { - network_access: crate::protocol::NetworkAccess::Restricted, - }, - file_system_policy: &FileSystemSandboxPolicy::unrestricted(), - network_policy: NetworkSandboxPolicy::Restricted, - sandbox: SandboxType::None, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, - codex_linux_sandbox_exe: None, - use_linux_sandbox_bwrap: false, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .expect("transform"); - - assert_eq!( - exec_request.file_system_sandbox_policy, - FileSystemSandboxPolicy::unrestricted() - ); - assert_eq!( - exec_request.network_sandbox_policy, - NetworkSandboxPolicy::Restricted - ); - } - - #[test] - fn normalize_additional_permissions_preserves_network() { - let temp_dir = TempDir::new().expect("create temp dir"); - let path = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); - let permissions = normalize_additional_permissions(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions { - read: Some(vec![path.clone()]), - write: Some(vec![path.clone()]), - }), - ..Default::default() - }) - .expect("permissions"); - - assert_eq!( - permissions.network, - Some(NetworkPermissions { - enabled: Some(true), - }) - ); - assert_eq!( - permissions.file_system, - Some(FileSystemPermissions { - read: Some(vec![path.clone()]), - write: Some(vec![path]), - }) - ); - } - - #[test] - fn normalize_additional_permissions_drops_empty_nested_profiles() { - let permissions = normalize_additional_permissions(PermissionProfile { - network: Some(NetworkPermissions { enabled: None }), - file_system: Some(FileSystemPermissions { - read: None, - write: None, - }), - macos: None, - }) - .expect("permissions"); - - assert_eq!(permissions, PermissionProfile::default()); - } - - #[cfg(target_os = "macos")] - #[test] - fn normalize_additional_permissions_preserves_default_macos_preferences_permission() { - let permissions = normalize_additional_permissions(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions::default()), - ..Default::default() - }) - .expect("permissions"); - - assert_eq!( - permissions, - PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions::default()), - ..Default::default() - } - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn intersect_permission_profiles_preserves_default_macos_grants() { - let requested = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(Vec::from(["/tmp/requested" - .try_into() - .expect("absolute path")])), - write: None, - }), - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: false, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }; - let granted = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(Vec::new()), - write: None, - }), - macos: Some(MacOsSeatbeltProfileExtensions::default()), - ..Default::default() - }; - - assert_eq!( - intersect_permission_profiles(requested, granted), - PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions::default()), - ..Default::default() - } - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn normalize_additional_permissions_preserves_macos_permissions() { - let permissions = normalize_additional_permissions(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }) - .expect("permissions"); - - assert_eq!( - permissions.macos, - Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }) - ); - } - - #[test] - fn read_only_additional_permissions_can_enable_network_without_writes() { - let temp_dir = TempDir::new().expect("create temp dir"); - let path = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); - let policy = sandbox_policy_with_additional_permissions( - &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![path.clone()], - }, - network_access: false, - }, - &PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions { - read: Some(vec![path.clone()]), - write: Some(Vec::new()), - }), - ..Default::default() - }, - ); - - assert_eq!( - policy, - SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![path], - }, - network_access: true, - } - ); - } - #[cfg(target_os = "macos")] - #[test] - fn effective_permissions_merge_macos_extensions_with_additional_permissions() { - let temp_dir = TempDir::new().expect("create temp dir"); - let path = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); - let effective_permissions = EffectiveSandboxPermissions::new( - &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![path.clone()], - }, - network_access: false, - }, - Some(&MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Calendar".to_string(), - ]), - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - Some(&PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![path]), - write: Some(Vec::new()), - }), - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }), - ); - - assert_eq!( - effective_permissions.macos_seatbelt_profile_extensions, - Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Calendar".to_string(), - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }) - ); - } - - #[test] - fn external_sandbox_additional_permissions_can_enable_network() { - let temp_dir = TempDir::new().expect("create temp dir"); - let path = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); - let policy = sandbox_policy_with_additional_permissions( - &SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - }, - &PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions { - read: Some(vec![path]), - write: Some(Vec::new()), - }), - ..Default::default() - }, - ); - - assert_eq!( - policy, - SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - } - ); - } - - #[test] - fn transform_additional_permissions_enable_network_for_external_sandbox() { - let manager = SandboxManager::new(); - let cwd = std::env::current_dir().expect("current dir"); - let temp_dir = TempDir::new().expect("create temp dir"); - let path = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); - let exec_request = manager - .transform(super::SandboxTransformRequest { - spec: super::CommandSpec { - program: "true".to_string(), - args: Vec::new(), - cwd: cwd.clone(), - env: HashMap::new(), - expiration: crate::exec::ExecExpiration::DefaultTimeout, - sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions, - additional_permissions: Some(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions { - read: Some(vec![path]), - write: Some(Vec::new()), - }), - ..Default::default() - }), - justification: None, - }, - policy: &SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - }, - file_system_policy: &FileSystemSandboxPolicy::unrestricted(), - network_policy: NetworkSandboxPolicy::Restricted, - sandbox: SandboxType::None, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, - codex_linux_sandbox_exe: None, - use_linux_sandbox_bwrap: false, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .expect("transform"); - - assert_eq!( - exec_request.sandbox_policy, - SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - } - ); - assert_eq!( - exec_request.network_sandbox_policy, - NetworkSandboxPolicy::Enabled - ); - } - - #[test] - fn transform_additional_permissions_preserves_denied_entries() { - let manager = SandboxManager::new(); - let cwd = std::env::current_dir().expect("current dir"); - let temp_dir = TempDir::new().expect("create temp dir"); - let workspace_root = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); - let allowed_path = workspace_root.join("allowed").expect("allowed path"); - let denied_path = workspace_root.join("denied").expect("denied path"); - let exec_request = manager - .transform(super::SandboxTransformRequest { - spec: super::CommandSpec { - program: "true".to_string(), - args: Vec::new(), - cwd: cwd.clone(), - env: HashMap::new(), - expiration: crate::exec::ExecExpiration::DefaultTimeout, - sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions, - additional_permissions: Some(PermissionProfile { - file_system: Some(FileSystemPermissions { - read: None, - write: Some(vec![allowed_path.clone()]), - }), - ..Default::default() - }), - justification: None, - }, - policy: &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: false, - }, - file_system_policy: &FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: denied_path.clone(), - }, - access: FileSystemAccessMode::None, - }, - ]), - network_policy: NetworkSandboxPolicy::Restricted, - sandbox: SandboxType::None, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, - codex_linux_sandbox_exe: None, - use_linux_sandbox_bwrap: false, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .expect("transform"); - - assert_eq!( - exec_request.file_system_sandbox_policy, - FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { path: denied_path }, - access: FileSystemAccessMode::None, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { path: allowed_path }, - access: FileSystemAccessMode::Write, - }, - ]) - ); - assert_eq!( - exec_request.network_sandbox_policy, - NetworkSandboxPolicy::Restricted - ); - } - - #[test] - fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roots() { - let temp_dir = TempDir::new().expect("create temp dir"); - let cwd = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); - let allowed_path = cwd.join("allowed").expect("allowed path"); - let denied_path = cwd.join("denied").expect("denied path"); - let merged_policy = merge_file_system_policy_with_additional_permissions( - &FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: denied_path.clone(), - }, - access: FileSystemAccessMode::None, - }, - ]), - vec![allowed_path.clone()], - Vec::new(), - ); - - assert_eq!( - merged_policy.entries.contains(&FileSystemSandboxEntry { - path: FileSystemPath::Path { path: denied_path }, - access: FileSystemAccessMode::None, - }), - true - ); - assert_eq!( - merged_policy.entries.contains(&FileSystemSandboxEntry { - path: FileSystemPath::Path { path: allowed_path }, - access: FileSystemAccessMode::Read, - }), - true - ); - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/sandboxing/mod_tests.rs b/codex-rs/core/src/sandboxing/mod_tests.rs new file mode 100644 index 0000000000..c20c2ef413 --- /dev/null +++ b/codex-rs/core/src/sandboxing/mod_tests.rs @@ -0,0 +1,723 @@ +#[cfg(target_os = "macos")] +use super::EffectiveSandboxPermissions; +use super::SandboxManager; +use super::effective_file_system_sandbox_policy; +#[cfg(target_os = "macos")] +use super::intersect_permission_profiles; +use super::merge_file_system_policy_with_additional_permissions; +use super::normalize_additional_permissions; +use super::sandbox_policy_with_additional_permissions; +use super::should_require_platform_sandbox; +use crate::exec::SandboxType; +use crate::protocol::NetworkAccess; +use crate::protocol::ReadOnlyAccess; +use crate::protocol::SandboxPolicy; +use crate::tools::sandboxing::SandboxablePreference; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::FileSystemPermissions; +#[cfg(target_os = "macos")] +use codex_protocol::models::MacOsAutomationPermission; +#[cfg(target_os = "macos")] +use codex_protocol::models::MacOsContactsPermission; +#[cfg(target_os = "macos")] +use codex_protocol::models::MacOsPreferencesPermission; +#[cfg(target_os = "macos")] +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use dunce::canonicalize; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use tempfile::TempDir; + +#[test] +fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() { + let manager = SandboxManager::new(); + let sandbox = manager.select_initial( + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Enabled, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + false, + ); + assert_eq!(sandbox, SandboxType::None); +} + +#[test] +fn danger_full_access_uses_platform_sandbox_with_network_requirements() { + let manager = SandboxManager::new(); + let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); + let sandbox = manager.select_initial( + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Enabled, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + true, + ); + assert_eq!(sandbox, expected); +} + +#[test] +fn restricted_file_system_uses_platform_sandbox_without_managed_network() { + let manager = SandboxManager::new(); + let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); + let sandbox = manager.select_initial( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }]), + NetworkSandboxPolicy::Enabled, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + false, + ); + assert_eq!(sandbox, expected); +} + +#[test] +fn full_access_restricted_policy_skips_platform_sandbox_when_network_is_enabled() { + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }]); + + assert_eq!( + should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false), + false + ); +} + +#[test] +fn root_write_policy_with_carveouts_still_uses_platform_sandbox() { + let blocked = AbsolutePathBuf::resolve_path_against_base( + "blocked", + std::env::current_dir().expect("current dir"), + ) + .expect("blocked path"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: blocked }, + access: FileSystemAccessMode::None, + }, + ]); + + assert_eq!( + should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false), + true + ); +} + +#[test] +fn full_access_restricted_policy_still_uses_platform_sandbox_for_restricted_network() { + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }]); + + assert_eq!( + should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Restricted, false), + true + ); +} + +#[test] +fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + let exec_request = manager + .transform(super::SandboxTransformRequest { + spec: super::CommandSpec { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + expiration: crate::exec::ExecExpiration::DefaultTimeout, + sandbox_permissions: super::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: None, + }, + policy: &SandboxPolicy::ExternalSandbox { + network_access: crate::protocol::NetworkAccess::Restricted, + }, + file_system_policy: &FileSystemSandboxPolicy::unrestricted(), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .expect("transform"); + + assert_eq!( + exec_request.file_system_sandbox_policy, + FileSystemSandboxPolicy::unrestricted() + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); +} + +#[test] +fn normalize_additional_permissions_preserves_network() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let permissions = normalize_additional_permissions(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: Some(vec![path.clone()]), + write: Some(vec![path.clone()]), + }), + ..Default::default() + }) + .expect("permissions"); + + assert_eq!( + permissions.network, + Some(NetworkPermissions { + enabled: Some(true), + }) + ); + assert_eq!( + permissions.file_system, + Some(FileSystemPermissions { + read: Some(vec![path.clone()]), + write: Some(vec![path]), + }) + ); +} + +#[test] +fn normalize_additional_permissions_drops_empty_nested_profiles() { + let permissions = normalize_additional_permissions(PermissionProfile { + network: Some(NetworkPermissions { enabled: None }), + file_system: Some(FileSystemPermissions { + read: None, + write: None, + }), + macos: None, + }) + .expect("permissions"); + + assert_eq!(permissions, PermissionProfile::default()); +} + +#[cfg(target_os = "macos")] +#[test] +fn normalize_additional_permissions_preserves_default_macos_preferences_permission() { + let permissions = normalize_additional_permissions(PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + }) + .expect("permissions"); + + assert_eq!( + permissions, + PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + } + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn intersect_permission_profiles_preserves_default_macos_grants() { + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::from(["/tmp/requested" + .try_into() + .expect("absolute path")])), + write: None, + }), + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_launch_services: false, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::None, + }), + ..Default::default() + }; + let granted = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::new()), + write: None, + }), + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + }; + + assert_eq!( + intersect_permission_profiles(requested, granted), + PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + } + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn normalize_additional_permissions_preserves_macos_permissions() { + let permissions = normalize_additional_permissions(PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_launch_services: true, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::None, + }), + ..Default::default() + }) + .expect("permissions"); + + assert_eq!( + permissions.macos, + Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_launch_services: true, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::None, + }) + ); +} + +#[test] +fn read_only_additional_permissions_can_enable_network_without_writes() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let policy = sandbox_policy_with_additional_permissions( + &SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![path.clone()], + }, + network_access: false, + }, + &PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: Some(vec![path.clone()]), + write: Some(Vec::new()), + }), + ..Default::default() + }, + ); + + assert_eq!( + policy, + SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![path], + }, + network_access: true, + } + ); +} +#[cfg(target_os = "macos")] +#[test] +fn effective_permissions_merge_macos_extensions_with_additional_permissions() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let effective_permissions = EffectiveSandboxPermissions::new( + &SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![path.clone()], + }, + network_access: false, + }, + Some(&MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadOnly, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Calendar".to_string(), + ]), + macos_launch_services: false, + macos_accessibility: false, + macos_calendar: false, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::None, + }), + Some(&PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![path]), + write: Some(Vec::new()), + }), + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_launch_services: true, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::None, + }), + ..Default::default() + }), + ); + + assert_eq!( + effective_permissions.macos_seatbelt_profile_extensions, + Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Calendar".to_string(), + "com.apple.Notes".to_string(), + ]), + macos_launch_services: true, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::None, + }) + ); +} + +#[test] +fn external_sandbox_additional_permissions_can_enable_network() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let policy = sandbox_policy_with_additional_permissions( + &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }, + &PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: Some(vec![path]), + write: Some(Vec::new()), + }), + ..Default::default() + }, + ); + + assert_eq!( + policy, + SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + } + ); +} + +#[test] +fn transform_additional_permissions_enable_network_for_external_sandbox() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let exec_request = manager + .transform(super::SandboxTransformRequest { + spec: super::CommandSpec { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + expiration: crate::exec::ExecExpiration::DefaultTimeout, + sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions, + additional_permissions: Some(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: Some(vec![path]), + write: Some(Vec::new()), + }), + ..Default::default() + }), + justification: None, + }, + policy: &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }, + file_system_policy: &FileSystemSandboxPolicy::unrestricted(), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .expect("transform"); + + assert_eq!( + exec_request.sandbox_policy, + SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + } + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Enabled + ); +} + +#[test] +fn transform_additional_permissions_preserves_denied_entries() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + let temp_dir = TempDir::new().expect("create temp dir"); + let workspace_root = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let allowed_path = workspace_root.join("allowed").expect("allowed path"); + let denied_path = workspace_root.join("denied").expect("denied path"); + let exec_request = manager + .transform(super::SandboxTransformRequest { + spec: super::CommandSpec { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + expiration: crate::exec::ExecExpiration::DefaultTimeout, + sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions, + additional_permissions: Some(PermissionProfile { + file_system: Some(FileSystemPermissions { + read: None, + write: Some(vec![allowed_path.clone()]), + }), + ..Default::default() + }), + justification: None, + }, + policy: &SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: false, + }, + file_system_policy: &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_path.clone(), + }, + access: FileSystemAccessMode::None, + }, + ]), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .expect("transform"); + + assert_eq!( + exec_request.file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: denied_path }, + access: FileSystemAccessMode::None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: allowed_path }, + access: FileSystemAccessMode::Write, + }, + ]) + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); +} + +#[test] +fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roots() { + let temp_dir = TempDir::new().expect("create temp dir"); + let cwd = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let allowed_path = cwd.join("allowed").expect("allowed path"); + let denied_path = cwd.join("denied").expect("denied path"); + let merged_policy = merge_file_system_policy_with_additional_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_path.clone(), + }, + access: FileSystemAccessMode::None, + }, + ]), + vec![allowed_path.clone()], + Vec::new(), + ); + + assert_eq!( + merged_policy.entries.contains(&FileSystemSandboxEntry { + path: FileSystemPath::Path { path: denied_path }, + access: FileSystemAccessMode::None, + }), + true + ); + assert_eq!( + merged_policy.entries.contains(&FileSystemSandboxEntry { + path: FileSystemPath::Path { path: allowed_path }, + access: FileSystemAccessMode::Read, + }), + true + ); +} + +#[test] +fn effective_file_system_sandbox_policy_returns_base_policy_without_additional_permissions() { + let temp_dir = TempDir::new().expect("create temp dir"); + let cwd = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let denied_path = cwd.join("denied").expect("denied path"); + let base_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: denied_path }, + access: FileSystemAccessMode::None, + }, + ]); + + let effective_policy = effective_file_system_sandbox_policy(&base_policy, None); + + assert_eq!(effective_policy, base_policy); +} + +#[test] +fn effective_file_system_sandbox_policy_merges_additional_write_roots() { + let temp_dir = TempDir::new().expect("create temp dir"); + let cwd = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let allowed_path = cwd.join("allowed").expect("allowed path"); + let denied_path = cwd.join("denied").expect("denied path"); + let base_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_path.clone(), + }, + access: FileSystemAccessMode::None, + }, + ]); + let additional_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![allowed_path.clone()]), + }), + ..Default::default() + }; + + let effective_policy = + effective_file_system_sandbox_policy(&base_policy, Some(&additional_permissions)); + + assert_eq!( + effective_policy.entries.contains(&FileSystemSandboxEntry { + path: FileSystemPath::Path { path: denied_path }, + access: FileSystemAccessMode::None, + }), + true + ); + assert_eq!( + effective_policy.entries.contains(&FileSystemSandboxEntry { + path: FileSystemPath::Path { path: allowed_path }, + access: FileSystemAccessMode::Write, + }), + true + ); +} diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index fa0538e384..672165526b 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -584,1064 +584,5 @@ fn macos_dir_params() -> Vec<(String, PathBuf)> { } #[cfg(test)] -mod tests { - use super::MACOS_SEATBELT_BASE_POLICY; - use super::ProxyPolicyInputs; - use super::UnixDomainSocketPolicy; - use super::create_seatbelt_command_args; - use super::create_seatbelt_command_args_for_policies_with_extensions; - use super::create_seatbelt_command_args_with_extensions; - use super::dynamic_network_policy; - use super::macos_dir_params; - use super::normalize_path_for_sandbox; - use super::unix_socket_dir_params; - use super::unix_socket_policy; - use crate::protocol::ReadOnlyAccess; - use crate::protocol::SandboxPolicy; - use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; - use crate::seatbelt_permissions::MacOsAutomationPermission; - use crate::seatbelt_permissions::MacOsContactsPermission; - use crate::seatbelt_permissions::MacOsPreferencesPermission; - use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; - use codex_protocol::permissions::FileSystemAccessMode; - use codex_protocol::permissions::FileSystemPath; - use codex_protocol::permissions::FileSystemSandboxEntry; - use codex_protocol::permissions::FileSystemSandboxPolicy; - use codex_protocol::permissions::NetworkSandboxPolicy; - use codex_utils_absolute_path::AbsolutePathBuf; - use pretty_assertions::assert_eq; - use std::fs; - use std::path::Path; - use std::path::PathBuf; - use std::process::Command; - use tempfile::TempDir; - - fn assert_seatbelt_denied(stderr: &[u8], path: &Path) { - let stderr = String::from_utf8_lossy(stderr); - let expected = format!("bash: {}: Operation not permitted\n", path.display()); - assert!( - stderr == expected - || stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted"), - "unexpected stderr: {stderr}" - ); - } - - fn absolute_path(path: &str) -> AbsolutePathBuf { - AbsolutePathBuf::from_absolute_path(Path::new(path)).expect("absolute path") - } - - fn seatbelt_policy_arg(args: &[String]) -> &str { - let policy_index = args - .iter() - .position(|arg| arg == "-p") - .expect("seatbelt args should include -p"); - args.get(policy_index + 1) - .expect("seatbelt args should include policy text") - } - - #[test] - fn base_policy_allows_node_cpu_sysctls() { - assert!( - MACOS_SEATBELT_BASE_POLICY.contains("(sysctl-name \"machdep.cpu.brand_string\")"), - "base policy must allow CPU brand lookup for os.cpus()" - ); - assert!( - MACOS_SEATBELT_BASE_POLICY.contains("(sysctl-name \"hw.model\")"), - "base policy must allow hardware model lookup for os.cpus()" - ); - } - - #[test] - fn create_seatbelt_args_routes_network_through_proxy_ports() { - let policy = dynamic_network_policy( - &SandboxPolicy::new_read_only_policy(), - false, - &ProxyPolicyInputs { - ports: vec![43128, 48081], - has_proxy_config: true, - allow_local_binding: false, - ..ProxyPolicyInputs::default() - }, - ); - - assert!( - policy.contains("(allow network-outbound (remote ip \"localhost:43128\"))"), - "expected HTTP proxy port allow rule in policy:\n{policy}" - ); - assert!( - policy.contains("(allow network-outbound (remote ip \"localhost:48081\"))"), - "expected SOCKS proxy port allow rule in policy:\n{policy}" - ); - assert!( - !policy.contains("\n(allow network-outbound)\n"), - "policy should not include blanket outbound allowance when proxy ports are present:\n{policy}" - ); - assert!( - !policy.contains("(allow network-bind (local ip \"localhost:*\"))"), - "policy should not allow loopback binding unless explicitly enabled:\n{policy}" - ); - assert!( - !policy.contains("(allow network-inbound (local ip \"localhost:*\"))"), - "policy should not allow loopback inbound unless explicitly enabled:\n{policy}" - ); - } - - #[test] - fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() { - let unreadable = absolute_path("/tmp/codex-unreadable"); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: crate::protocol::FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Write, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { path: unreadable }, - access: FileSystemAccessMode::None, - }, - ]); - - let args = create_seatbelt_command_args_for_policies_with_extensions( - vec!["/bin/true".to_string()], - &file_system_policy, - NetworkSandboxPolicy::Restricted, - Path::new("/"), - false, - None, - None, - ); - - let policy = seatbelt_policy_arg(&args); - assert!( - policy.contains("(require-not (subpath (param \"READABLE_ROOT_0_RO_0\")))"), - "expected read carveout in policy:\n{policy}" - ); - assert!( - policy.contains("(require-not (subpath (param \"WRITABLE_ROOT_0_RO_0\")))"), - "expected write carveout in policy:\n{policy}" - ); - assert!( - args.iter() - .any(|arg| arg == "-DREADABLE_ROOT_0_RO_0=/tmp/codex-unreadable"), - "expected read carveout parameter in args: {args:#?}" - ); - assert!( - args.iter() - .any(|arg| arg == "-DWRITABLE_ROOT_0_RO_0=/tmp/codex-unreadable"), - "expected write carveout parameter in args: {args:#?}" - ); - } - - #[test] - fn explicit_unreadable_paths_are_excluded_from_readable_roots() { - let root = absolute_path("/tmp/codex-readable"); - let unreadable = absolute_path("/tmp/codex-readable/private"); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Path { path: root }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { path: unreadable }, - access: FileSystemAccessMode::None, - }, - ]); - - let args = create_seatbelt_command_args_for_policies_with_extensions( - vec!["/bin/true".to_string()], - &file_system_policy, - NetworkSandboxPolicy::Restricted, - Path::new("/"), - false, - None, - None, - ); - - let policy = seatbelt_policy_arg(&args); - assert!( - policy.contains("(require-not (subpath (param \"READABLE_ROOT_0_RO_0\")))"), - "expected read carveout in policy:\n{policy}" - ); - assert!( - args.iter() - .any(|arg| arg == "-DREADABLE_ROOT_0=/tmp/codex-readable"), - "expected readable root parameter in args: {args:#?}" - ); - assert!( - args.iter() - .any(|arg| arg == "-DREADABLE_ROOT_0_RO_0=/tmp/codex-readable/private"), - "expected read carveout parameter in args: {args:#?}" - ); - } - - #[test] - fn seatbelt_args_include_macos_permission_extensions() { - let cwd = std::env::temp_dir(); - let args = create_seatbelt_command_args_with_extensions( - vec!["echo".to_string(), "ok".to_string()], - &SandboxPolicy::new_read_only_policy(), - cwd.as_path(), - false, - None, - Some(&MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ); - let policy = &args[1]; - - assert!(policy.contains("(allow user-preference-write)")); - assert!(policy.contains("(appleevent-destination \"com.apple.Notes\")")); - assert!(policy.contains("com.apple.axserver")); - assert!(policy.contains("com.apple.CalendarAgent")); - } - - #[test] - fn bundle_id_automation_keeps_lsopen_denied() { - let tmp = TempDir::new().expect("tempdir"); - let cwd = tmp.path().join("cwd"); - fs::create_dir_all(&cwd).expect("create cwd"); - - let args = create_seatbelt_command_args_with_extensions( - vec![ - "/usr/bin/python3".to_string(), - "-c".to_string(), - r#"import ctypes -import os -import sys -lib = ctypes.CDLL("/usr/lib/libsandbox.1.dylib") -lib.sandbox_check.restype = ctypes.c_int -allowed = lib.sandbox_check(os.getpid(), b"lsopen", 0) == 0 -sys.exit(0 if allowed else 13) -"# - .to_string(), - ], - &SandboxPolicy::new_read_only_policy(), - cwd.as_path(), - false, - None, - Some(&MacOsSeatbeltProfileExtensions { - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - ..Default::default() - }), - ); - - let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - .args(&args) - .current_dir(&cwd) - .output() - .expect("execute seatbelt command"); - - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted") { - return; - } - - assert_eq!( - Some(13), - output.status.code(), - "lsopen should remain denied even with bundle-scoped automation\nstdout: {}\nstderr: {stderr}", - String::from_utf8_lossy(&output.stdout), - ); - } - - #[test] - fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() { - let cwd = std::env::temp_dir(); - let args = create_seatbelt_command_args( - vec!["echo".to_string(), "ok".to_string()], - &SandboxPolicy::new_read_only_policy(), - cwd.as_path(), - false, - None, - ); - let policy = &args[1]; - assert!(policy.contains("(allow user-preference-read)")); - assert!(!policy.contains("(allow user-preference-write)")); - } - - #[test] - fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { - let tmp = TempDir::new().expect("tempdir"); - let cwd = tmp.path().join("workspace"); - fs::create_dir_all(cwd.join("docs")).expect("create docs"); - let docs = AbsolutePathBuf::from_absolute_path(cwd.join("docs")).expect("absolute docs"); - let args = create_seatbelt_command_args( - vec!["/bin/true".to_string()], - &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![docs.clone()], - }, - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }, - cwd.as_path(), - false, - None, - ); - - let docs_param = format!("-DWRITABLE_ROOT_0_RO_0={}", docs.as_path().display()); - assert!( - !seatbelt_policy_arg(&args).contains("WRITABLE_ROOT_0_RO_0"), - "legacy workspace-write readable roots under cwd should not become seatbelt carveouts:\n{args:#?}" - ); - assert!( - !args.iter().any(|arg| arg == &docs_param), - "unexpected seatbelt carveout parameter for redundant legacy readable root: {args:#?}" - ); - } - - #[test] - fn seatbelt_args_default_extension_profile_keeps_preferences_read_access() { - let cwd = std::env::temp_dir(); - let args = create_seatbelt_command_args_with_extensions( - vec!["echo".to_string(), "ok".to_string()], - &SandboxPolicy::new_read_only_policy(), - cwd.as_path(), - false, - None, - Some(&MacOsSeatbeltProfileExtensions::default()), - ); - let policy = &args[1]; - assert!(!policy.contains("appleevent-send")); - assert!(!policy.contains("com.apple.axserver")); - assert!(!policy.contains("com.apple.CalendarAgent")); - assert!(policy.contains("(allow user-preference-read)")); - assert!(!policy.contains("user-preference-write")); - } - - #[test] - fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() { - let policy = dynamic_network_policy( - &SandboxPolicy::new_read_only_policy(), - false, - &ProxyPolicyInputs { - ports: vec![43128], - has_proxy_config: true, - allow_local_binding: true, - ..ProxyPolicyInputs::default() - }, - ); - - assert!( - policy.contains("(allow network-bind (local ip \"localhost:*\"))"), - "policy should allow loopback local binding when explicitly enabled:\n{policy}" - ); - assert!( - policy.contains("(allow network-inbound (local ip \"localhost:*\"))"), - "policy should allow loopback inbound when explicitly enabled:\n{policy}" - ); - assert!( - policy.contains("(allow network-outbound (remote ip \"localhost:*\"))"), - "policy should allow loopback outbound when explicitly enabled:\n{policy}" - ); - assert!( - !policy.contains("\n(allow network-outbound)\n"), - "policy should keep proxy-routed behavior without blanket outbound allowance:\n{policy}" - ); - } - - #[test] - fn dynamic_network_policy_preserves_restricted_policy_when_proxy_config_without_ports() { - let policy = dynamic_network_policy( - &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: Default::default(), - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - false, - &ProxyPolicyInputs { - ports: vec![], - has_proxy_config: true, - allow_local_binding: false, - ..ProxyPolicyInputs::default() - }, - ); - - assert!( - policy.contains("(socket-domain AF_SYSTEM)"), - "policy should keep the restricted network profile when proxy config is present without ports:\n{policy}" - ); - assert!( - !policy.contains("\n(allow network-outbound)\n"), - "policy should not include blanket outbound allowance when proxy config is present without ports:\n{policy}" - ); - assert!( - !policy.contains("(allow network-outbound (remote ip \"localhost:"), - "policy should not include proxy port allowance when proxy config is present without ports:\n{policy}" - ); - } - - #[test] - fn dynamic_network_policy_preserves_restricted_policy_for_managed_network_without_proxy_config() - { - let policy = dynamic_network_policy( - &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: Default::default(), - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - true, - &ProxyPolicyInputs { - ports: vec![], - has_proxy_config: false, - allow_local_binding: false, - ..ProxyPolicyInputs::default() - }, - ); - - assert!( - policy.contains("(socket-domain AF_SYSTEM)"), - "policy should keep the restricted network profile when managed network is active without proxy endpoints:\n{policy}" - ); - assert!( - !policy.contains("\n(allow network-outbound)\n"), - "policy should not include blanket outbound allowance when managed network is active without proxy endpoints:\n{policy}" - ); - } - - #[test] - fn create_seatbelt_args_allowlists_unix_socket_paths() { - let policy = dynamic_network_policy( - &SandboxPolicy::new_read_only_policy(), - false, - &ProxyPolicyInputs { - ports: vec![43128], - has_proxy_config: true, - allow_local_binding: false, - unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { - allowed: vec![absolute_path("/tmp/example.sock")], - }, - }, - ); - - assert!( - policy.contains("(allow system-socket (socket-domain AF_UNIX))"), - "policy should allow AF_UNIX socket creation for configured unix sockets:\n{policy}" - ); - assert!( - policy.contains( - "(allow network-bind (local unix-socket (subpath (param \"UNIX_SOCKET_PATH_0\"))))" - ), - "policy should allow binding explicitly configured unix sockets:\n{policy}" - ); - assert!( - policy.contains( - "(allow network-outbound (remote unix-socket (subpath (param \"UNIX_SOCKET_PATH_0\"))))" - ), - "policy should allow connecting to explicitly configured unix sockets:\n{policy}" - ); - assert!( - !policy.contains("(allow network* (subpath"), - "policy should no longer use the generic subpath unix-socket rules:\n{policy}" - ); - } - - #[test] - fn unix_socket_policy_non_empty_output_is_newline_terminated() { - let allowlist_policy = unix_socket_policy(&ProxyPolicyInputs { - unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { - allowed: vec![absolute_path("/tmp/example.sock")], - }, - ..ProxyPolicyInputs::default() - }); - assert!( - allowlist_policy.ends_with('\n'), - "allowlist unix socket policy should end with a newline:\n{allowlist_policy}" - ); - - let allow_all_policy = unix_socket_policy(&ProxyPolicyInputs { - unix_domain_socket_policy: UnixDomainSocketPolicy::AllowAll, - ..ProxyPolicyInputs::default() - }); - assert!( - allow_all_policy.ends_with('\n'), - "allow-all unix socket policy should end with a newline:\n{allow_all_policy}" - ); - } - - #[test] - fn unix_socket_dir_params_use_stable_param_names() { - let params = unix_socket_dir_params(&ProxyPolicyInputs { - unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { - allowed: vec![ - absolute_path("/tmp/b.sock"), - absolute_path("/tmp/a.sock"), - absolute_path("/tmp/a.sock"), - ], - }, - ..ProxyPolicyInputs::default() - }); - - assert_eq!( - params, - vec![ - ( - "UNIX_SOCKET_PATH_0".to_string(), - PathBuf::from("/tmp/a.sock") - ), - ( - "UNIX_SOCKET_PATH_1".to_string(), - PathBuf::from("/tmp/b.sock") - ), - ] - ); - } - - #[test] - fn normalize_path_for_sandbox_rejects_relative_paths() { - assert_eq!(normalize_path_for_sandbox(Path::new("relative.sock")), None); - } - - #[test] - fn create_seatbelt_args_allows_all_unix_sockets_when_enabled() { - let policy = dynamic_network_policy( - &SandboxPolicy::new_read_only_policy(), - false, - &ProxyPolicyInputs { - ports: vec![43128], - has_proxy_config: true, - allow_local_binding: false, - unix_domain_socket_policy: UnixDomainSocketPolicy::AllowAll, - }, - ); - - assert!( - policy.contains("(allow system-socket (socket-domain AF_UNIX))"), - "policy should allow AF_UNIX socket creation when unix sockets are enabled:\n{policy}" - ); - assert!( - policy.contains("(allow network-bind (local unix-socket))"), - "policy should allow binding unix sockets when enabled:\n{policy}" - ); - assert!( - policy.contains("(allow network-outbound (remote unix-socket))"), - "policy should allow connecting to unix sockets when enabled:\n{policy}" - ); - assert!( - !policy.contains("(allow network* (subpath"), - "policy should no longer use the generic subpath unix-socket rules:\n{policy}" - ); - } - - #[test] - fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() { - let policy = dynamic_network_policy( - &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: Default::default(), - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - false, - &ProxyPolicyInputs { - ports: vec![43128], - has_proxy_config: true, - allow_local_binding: false, - ..ProxyPolicyInputs::default() - }, - ); - - assert!( - policy.contains("(allow network-outbound (remote ip \"localhost:43128\"))"), - "expected proxy endpoint allow rule in policy:\n{policy}" - ); - assert!( - !policy.contains("\n(allow network-outbound)\n"), - "policy should not include blanket outbound allowance when proxy is configured:\n{policy}" - ); - assert!( - !policy.contains("\n(allow network-inbound)\n"), - "policy should not include blanket inbound allowance when proxy is configured:\n{policy}" - ); - } - - #[test] - fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { - // Create a temporary workspace with two writable roots: one containing - // top-level .git and .codex directories and one without them. - let tmp = TempDir::new().expect("tempdir"); - let PopulatedTmp { - vulnerable_root, - vulnerable_root_canonical, - dot_git_canonical, - dot_codex_canonical, - empty_root, - empty_root_canonical, - } = populate_tmpdir(tmp.path()); - let cwd = tmp.path().join("cwd"); - fs::create_dir_all(&cwd).expect("create cwd"); - - // Build a policy that only includes the two test roots as writable and - // does not automatically include defaults TMPDIR or /tmp. - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![vulnerable_root, empty_root] - .into_iter() - .map(|p| p.try_into().unwrap()) - .collect(), - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - - // Create the Seatbelt command to wrap a shell command that tries to - // write to .codex/config.toml in the vulnerable root. - let shell_command: Vec = [ - "bash", - "-c", - "echo 'sandbox_mode = \"danger-full-access\"' > \"$1\"", - "bash", - dot_codex_canonical - .join("config.toml") - .to_string_lossy() - .as_ref(), - ] - .iter() - .map(std::string::ToString::to_string) - .collect(); - let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None); - - // Build the expected policy text using a raw string for readability. - // Note that the policy includes: - // - the base policy, - // - read-only access to the filesystem, - // - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2. - let expected_policy = format!( - r#"{MACOS_SEATBELT_BASE_POLICY} -; allow read-only file operations -(allow file-read*) -(allow file-write* -(subpath (param "WRITABLE_ROOT_0")) (require-all (subpath (param "WRITABLE_ROOT_1")) (require-not (subpath (param "WRITABLE_ROOT_1_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_1_RO_1"))) ) (subpath (param "WRITABLE_ROOT_2")) -) - -; macOS permission profile extensions -(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) -(allow mach-lookup - (global-name "com.apple.cfprefsd.daemon") - (global-name "com.apple.cfprefsd.agent") - (local-name "com.apple.cfprefsd.agent")) -(allow user-preference-read) -"#, - ); - - assert_eq!(seatbelt_policy_arg(&args), expected_policy); - - let expected_definitions = [ - format!( - "-DWRITABLE_ROOT_0={}", - cwd.canonicalize() - .expect("canonicalize cwd") - .to_string_lossy() - ), - format!( - "-DWRITABLE_ROOT_1={}", - vulnerable_root_canonical.to_string_lossy() - ), - format!( - "-DWRITABLE_ROOT_1_RO_0={}", - dot_git_canonical.to_string_lossy() - ), - format!( - "-DWRITABLE_ROOT_1_RO_1={}", - dot_codex_canonical.to_string_lossy() - ), - format!( - "-DWRITABLE_ROOT_2={}", - empty_root_canonical.to_string_lossy() - ), - ]; - for expected_definition in expected_definitions { - assert!( - args.contains(&expected_definition), - "expected definition arg `{expected_definition}` in {args:#?}" - ); - } - for (key, value) in macos_dir_params() { - let expected_definition = format!("-D{key}={}", value.to_string_lossy()); - assert!( - args.contains(&expected_definition), - "expected definition arg `{expected_definition}` in {args:#?}" - ); - } - - let command_index = args - .iter() - .position(|arg| arg == "--") - .expect("seatbelt args should include command separator"); - assert_eq!(args[command_index + 1..], shell_command); - - // Verify that .codex/config.toml cannot be modified under the generated - // Seatbelt policy. - let config_toml = dot_codex_canonical.join("config.toml"); - let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - .args(&args) - .current_dir(&cwd) - .output() - .expect("execute seatbelt command"); - assert_eq!( - "sandbox_mode = \"read-only\"\n", - String::from_utf8_lossy(&fs::read(&config_toml).expect("read config.toml")), - "config.toml should contain its original contents because it should not have been modified" - ); - assert!( - !output.status.success(), - "command to write {} should fail under seatbelt", - &config_toml.display() - ); - assert_seatbelt_denied(&output.stderr, &config_toml); - - // Create a similar Seatbelt command that tries to write to a file in - // the .git folder, which should also be blocked. - let pre_commit_hook = dot_git_canonical.join("hooks").join("pre-commit"); - let shell_command_git: Vec = [ - "bash", - "-c", - "echo 'pwned!' > \"$1\"", - "bash", - pre_commit_hook.to_string_lossy().as_ref(), - ] - .iter() - .map(std::string::ToString::to_string) - .collect(); - let write_hooks_file_args = - create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None); - let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - .args(&write_hooks_file_args) - .current_dir(&cwd) - .output() - .expect("execute seatbelt command"); - assert!( - !fs::exists(&pre_commit_hook).expect("exists pre-commit hook"), - "{} should not exist because it should not have been created", - pre_commit_hook.display() - ); - assert!( - !output.status.success(), - "command to write {} should fail under seatbelt", - &pre_commit_hook.display() - ); - assert_seatbelt_denied(&output.stderr, &pre_commit_hook); - - // Verify that writing a file to the folder containing .git and .codex is allowed. - let allowed_file = vulnerable_root_canonical.join("allowed.txt"); - let shell_command_allowed: Vec = [ - "bash", - "-c", - "echo 'this is allowed' > \"$1\"", - "bash", - allowed_file.to_string_lossy().as_ref(), - ] - .iter() - .map(std::string::ToString::to_string) - .collect(); - let write_allowed_file_args = - create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None); - let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - .args(&write_allowed_file_args) - .current_dir(&cwd) - .output() - .expect("execute seatbelt command"); - let stderr = String::from_utf8_lossy(&output.stderr); - if !output.status.success() - && stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted") - { - return; - } - assert!( - output.status.success(), - "command to write {} should succeed under seatbelt", - &allowed_file.display() - ); - assert_eq!( - "this is allowed\n", - String::from_utf8_lossy(&fs::read(&allowed_file).expect("read allowed.txt")), - "{} should contain the written text", - allowed_file.display() - ); - } - - #[test] - fn create_seatbelt_args_with_read_only_git_pointer_file() { - let tmp = TempDir::new().expect("tempdir"); - let worktree_root = tmp.path().join("worktree_root"); - fs::create_dir_all(&worktree_root).expect("create worktree_root"); - let gitdir = worktree_root.join("actual-gitdir"); - fs::create_dir_all(&gitdir).expect("create gitdir"); - let gitdir_config = gitdir.join("config"); - let gitdir_config_contents = "[core]\n"; - fs::write(&gitdir_config, gitdir_config_contents).expect("write gitdir config"); - - let dot_git = worktree_root.join(".git"); - let dot_git_contents = format!("gitdir: {}\n", gitdir.to_string_lossy()); - fs::write(&dot_git, &dot_git_contents).expect("write .git pointer"); - - let cwd = tmp.path().join("cwd"); - fs::create_dir_all(&cwd).expect("create cwd"); - - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![worktree_root.try_into().expect("worktree_root is absolute")], - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - - let shell_command: Vec = [ - "bash", - "-c", - "echo 'pwned!' > \"$1\"", - "bash", - dot_git.to_string_lossy().as_ref(), - ] - .iter() - .map(std::string::ToString::to_string) - .collect(); - let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None); - - let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - .args(&args) - .current_dir(&cwd) - .output() - .expect("execute seatbelt command"); - - assert_eq!( - dot_git_contents, - String::from_utf8_lossy(&fs::read(&dot_git).expect("read .git pointer")), - ".git pointer file should not be modified under seatbelt" - ); - assert!( - !output.status.success(), - "command to write {} should fail under seatbelt", - dot_git.display() - ); - assert_seatbelt_denied(&output.stderr, &dot_git); - - let shell_command_gitdir: Vec = [ - "bash", - "-c", - "echo 'pwned!' > \"$1\"", - "bash", - gitdir_config.to_string_lossy().as_ref(), - ] - .iter() - .map(std::string::ToString::to_string) - .collect(); - let gitdir_args = - create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None); - let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - .args(&gitdir_args) - .current_dir(&cwd) - .output() - .expect("execute seatbelt command"); - - assert_eq!( - gitdir_config_contents, - String::from_utf8_lossy(&fs::read(&gitdir_config).expect("read gitdir config")), - "gitdir config should contain its original contents because it should not have been modified" - ); - assert!( - !output.status.success(), - "command to write {} should fail under seatbelt", - gitdir_config.display() - ); - assert_seatbelt_denied(&output.stderr, &gitdir_config); - } - - #[test] - fn create_seatbelt_args_for_cwd_as_git_repo() { - // Create a temporary workspace with two writable roots: one containing - // top-level .git and .codex directories and one without them. - let tmp = TempDir::new().expect("tempdir"); - let PopulatedTmp { - vulnerable_root, - vulnerable_root_canonical, - dot_git_canonical, - dot_codex_canonical, - .. - } = populate_tmpdir(tmp.path()); - - // Build a policy that does not specify any writable_roots, but does - // use the default ones (cwd and TMPDIR) and verifies the `.git` and - // `.codex` checks are done properly for cwd. - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; - - let shell_command: Vec = [ - "bash", - "-c", - "echo 'sandbox_mode = \"danger-full-access\"' > \"$1\"", - "bash", - dot_codex_canonical - .join("config.toml") - .to_string_lossy() - .as_ref(), - ] - .iter() - .map(std::string::ToString::to_string) - .collect(); - let args = create_seatbelt_command_args( - shell_command.clone(), - &policy, - vulnerable_root.as_path(), - false, - None, - ); - - let tmpdir_env_var = std::env::var("TMPDIR") - .ok() - .map(PathBuf::from) - .and_then(|p| p.canonicalize().ok()) - .map(|p| p.to_string_lossy().to_string()); - - let tempdir_policy_entry = if tmpdir_env_var.is_some() { - r#" (subpath (param "WRITABLE_ROOT_2"))"# - } else { - "" - }; - - // Build the expected policy text using a raw string for readability. - // Note that the policy includes: - // - the base policy, - // - read-only access to the filesystem, - // - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2. - let expected_policy = format!( - r#"{MACOS_SEATBELT_BASE_POLICY} -; allow read-only file operations -(allow file-read*) -(allow file-write* -(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry} -) - -; macOS permission profile extensions -(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) -(allow mach-lookup - (global-name "com.apple.cfprefsd.daemon") - (global-name "com.apple.cfprefsd.agent") - (local-name "com.apple.cfprefsd.agent")) -(allow user-preference-read) -"#, - ); - - let mut expected_args = vec![ - "-p".to_string(), - expected_policy, - format!( - "-DWRITABLE_ROOT_0={}", - vulnerable_root_canonical.to_string_lossy() - ), - format!( - "-DWRITABLE_ROOT_0_RO_0={}", - dot_git_canonical.to_string_lossy() - ), - format!( - "-DWRITABLE_ROOT_0_RO_1={}", - dot_codex_canonical.to_string_lossy() - ), - format!( - "-DWRITABLE_ROOT_1={}", - PathBuf::from("/tmp") - .canonicalize() - .expect("canonicalize /tmp") - .to_string_lossy() - ), - ]; - - if let Some(p) = tmpdir_env_var { - expected_args.push(format!("-DWRITABLE_ROOT_2={p}")); - } - - expected_args.extend( - macos_dir_params() - .into_iter() - .map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())), - ); - - expected_args.push("--".to_string()); - expected_args.extend(shell_command); - - assert_eq!(expected_args, args); - } - - struct PopulatedTmp { - /// Path containing a .git and .codex subfolder. - /// For the purposes of this test, we consider this a "vulnerable" root - /// because a bad actor could write to .git/hooks/pre-commit so an - /// unsuspecting user would run code as privileged the next time they - /// ran `git commit` themselves, or modified .codex/config.toml to - /// contain `sandbox_mode = "danger-full-access"` so the agent would - /// have full privileges the next time it ran in that repo. - vulnerable_root: PathBuf, - vulnerable_root_canonical: PathBuf, - dot_git_canonical: PathBuf, - dot_codex_canonical: PathBuf, - - /// Path without .git or .codex subfolders. - empty_root: PathBuf, - /// Canonicalized version of `empty_root`. - empty_root_canonical: PathBuf, - } - - fn populate_tmpdir(tmp: &Path) -> PopulatedTmp { - let vulnerable_root = tmp.join("vulnerable_root"); - fs::create_dir_all(&vulnerable_root).expect("create vulnerable_root"); - - // TODO(mbolin): Should also support the case where `.git` is a file - // with a gitdir: ... line. - Command::new("git") - .arg("init") - .arg(".") - .current_dir(&vulnerable_root) - .output() - .expect("git init ."); - - fs::create_dir_all(vulnerable_root.join(".codex")).expect("create .codex"); - fs::write( - vulnerable_root.join(".codex").join("config.toml"), - "sandbox_mode = \"read-only\"\n", - ) - .expect("write .codex/config.toml"); - - let empty_root = tmp.join("empty_root"); - fs::create_dir_all(&empty_root).expect("create empty_root"); - - // Ensure we have canonical paths for -D parameter matching. - let vulnerable_root_canonical = vulnerable_root - .canonicalize() - .expect("canonicalize vulnerable_root"); - let dot_git_canonical = vulnerable_root_canonical.join(".git"); - let dot_codex_canonical = vulnerable_root_canonical.join(".codex"); - let empty_root_canonical = empty_root.canonicalize().expect("canonicalize empty_root"); - PopulatedTmp { - vulnerable_root, - vulnerable_root_canonical, - dot_git_canonical, - dot_codex_canonical, - empty_root, - empty_root_canonical, - } - } -} +#[path = "seatbelt_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/seatbelt_permissions.rs b/codex-rs/core/src/seatbelt_permissions.rs index 219ca332e7..5cbfd65094 100644 --- a/codex-rs/core/src/seatbelt_permissions.rs +++ b/codex-rs/core/src/seatbelt_permissions.rs @@ -188,157 +188,5 @@ fn is_valid_bundle_id(bundle_id: &str) -> bool { } #[cfg(test)] -mod tests { - use super::MacOsAutomationPermission; - use super::MacOsContactsPermission; - use super::MacOsPreferencesPermission; - use super::MacOsSeatbeltProfileExtensions; - use super::build_seatbelt_extensions; - - #[test] - fn preferences_read_only_emits_read_clauses_only() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - ..Default::default() - }); - assert!(policy.policy.contains("(allow user-preference-read)")); - assert!(!policy.policy.contains("(allow user-preference-write)")); - } - - #[test] - fn preferences_read_write_emits_write_clauses() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - ..Default::default() - }); - assert!(policy.policy.contains("(allow user-preference-read)")); - assert!(policy.policy.contains("(allow user-preference-write)")); - assert!(policy.policy.contains( - "(allow ipc-posix-shm-write-create (ipc-posix-name-prefix \"apple.cfprefs.\"))" - )); - } - - #[test] - fn automation_all_emits_unscoped_appleevents() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_automation: MacOsAutomationPermission::All, - ..Default::default() - }); - assert!(policy.policy.contains("(allow appleevent-send)")); - assert!(policy.policy.contains("com.apple.coreservices.appleevents")); - } - - #[test] - fn automation_bundle_ids_are_normalized_and_scoped() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - " com.apple.Notes ".to_string(), - "com.apple.Calendar".to_string(), - "bad bundle".to_string(), - "com.apple.Notes".to_string(), - ]), - ..Default::default() - }); - assert!( - policy - .policy - .contains("(appleevent-destination \"com.apple.Calendar\")") - ); - assert!( - policy - .policy - .contains("(appleevent-destination \"com.apple.Notes\")") - ); - assert!(!policy.policy.contains("bad bundle")); - assert!(policy.policy.contains("com.apple.coreservices.appleevents")); - } - - #[test] - fn launch_services_emit_launch_clauses() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_launch_services: true, - ..Default::default() - }); - assert!( - policy - .policy - .contains("com.apple.coreservices.launchservicesd") - ); - assert!(policy.policy.contains("com.apple.lsd.mapdb")); - assert!( - policy - .policy - .contains("com.apple.coreservices.quarantine-resolver") - ); - assert!(policy.policy.contains("com.apple.lsd.modifydb")); - assert!(policy.policy.contains("(allow lsopen)")); - } - - #[test] - fn accessibility_and_calendar_emit_mach_lookups() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_accessibility: true, - macos_calendar: true, - ..Default::default() - }); - assert!(policy.policy.contains("com.apple.axserver")); - assert!(policy.policy.contains("com.apple.CalendarAgent")); - } - - #[test] - fn reminders_emit_calendar_agent_and_remindd_lookups() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_reminders: true, - ..Default::default() - }); - assert!(policy.policy.contains("com.apple.CalendarAgent")); - assert!(policy.policy.contains("com.apple.remindd")); - } - - #[test] - fn contacts_read_only_emit_contacts_read_clauses() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_contacts: MacOsContactsPermission::ReadOnly, - ..Default::default() - }); - - assert!( - policy - .policy - .contains("(subpath \"/System/Library/Address Book Plug-Ins\")") - ); - assert!( - policy - .policy - .contains("(subpath (param \"ADDRESSBOOK_DIR\"))") - ); - assert!(policy.policy.contains("com.apple.contactsd.persistence")); - assert!(policy.policy.contains("com.apple.accountsd.accountmanager")); - assert!(!policy.policy.contains("com.apple.securityd.xpc")); - assert!( - policy - .dir_params - .iter() - .any(|(key, _)| key == "ADDRESSBOOK_DIR") - ); - } - - #[test] - fn contacts_read_write_emit_write_clauses() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_contacts: MacOsContactsPermission::ReadWrite, - ..Default::default() - }); - - assert!(policy.policy.contains("(subpath \"/var/folders\")")); - assert!(policy.policy.contains("(subpath \"/private/var/folders\")")); - assert!(policy.policy.contains("com.apple.securityd.xpc")); - } - - #[test] - fn default_extensions_emit_preferences_read_only_policy() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()); - assert!(policy.policy.contains("(allow user-preference-read)")); - assert!(!policy.policy.contains("(allow user-preference-write)")); - } -} +#[path = "seatbelt_permissions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/seatbelt_permissions_tests.rs b/codex-rs/core/src/seatbelt_permissions_tests.rs new file mode 100644 index 0000000000..b52ccdfcb2 --- /dev/null +++ b/codex-rs/core/src/seatbelt_permissions_tests.rs @@ -0,0 +1,154 @@ +use super::MacOsAutomationPermission; +use super::MacOsContactsPermission; +use super::MacOsPreferencesPermission; +use super::MacOsSeatbeltProfileExtensions; +use super::build_seatbelt_extensions; + +#[test] +fn preferences_read_only_emits_read_clauses_only() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadOnly, + ..Default::default() + }); + assert!(policy.policy.contains("(allow user-preference-read)")); + assert!(!policy.policy.contains("(allow user-preference-write)")); +} + +#[test] +fn preferences_read_write_emits_write_clauses() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + ..Default::default() + }); + assert!(policy.policy.contains("(allow user-preference-read)")); + assert!(policy.policy.contains("(allow user-preference-write)")); + assert!( + policy.policy.contains( + "(allow ipc-posix-shm-write-create (ipc-posix-name-prefix \"apple.cfprefs.\"))" + ) + ); +} + +#[test] +fn automation_all_emits_unscoped_appleevents() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_automation: MacOsAutomationPermission::All, + ..Default::default() + }); + assert!(policy.policy.contains("(allow appleevent-send)")); + assert!(policy.policy.contains("com.apple.coreservices.appleevents")); +} + +#[test] +fn automation_bundle_ids_are_normalized_and_scoped() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + " com.apple.Notes ".to_string(), + "com.apple.Calendar".to_string(), + "bad bundle".to_string(), + "com.apple.Notes".to_string(), + ]), + ..Default::default() + }); + assert!( + policy + .policy + .contains("(appleevent-destination \"com.apple.Calendar\")") + ); + assert!( + policy + .policy + .contains("(appleevent-destination \"com.apple.Notes\")") + ); + assert!(!policy.policy.contains("bad bundle")); + assert!(policy.policy.contains("com.apple.coreservices.appleevents")); +} + +#[test] +fn launch_services_emit_launch_clauses() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_launch_services: true, + ..Default::default() + }); + assert!( + policy + .policy + .contains("com.apple.coreservices.launchservicesd") + ); + assert!(policy.policy.contains("com.apple.lsd.mapdb")); + assert!( + policy + .policy + .contains("com.apple.coreservices.quarantine-resolver") + ); + assert!(policy.policy.contains("com.apple.lsd.modifydb")); + assert!(policy.policy.contains("(allow lsopen)")); +} + +#[test] +fn accessibility_and_calendar_emit_mach_lookups() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_accessibility: true, + macos_calendar: true, + ..Default::default() + }); + assert!(policy.policy.contains("com.apple.axserver")); + assert!(policy.policy.contains("com.apple.CalendarAgent")); +} + +#[test] +fn reminders_emit_calendar_agent_and_remindd_lookups() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_reminders: true, + ..Default::default() + }); + assert!(policy.policy.contains("com.apple.CalendarAgent")); + assert!(policy.policy.contains("com.apple.remindd")); +} + +#[test] +fn contacts_read_only_emit_contacts_read_clauses() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_contacts: MacOsContactsPermission::ReadOnly, + ..Default::default() + }); + + assert!( + policy + .policy + .contains("(subpath \"/System/Library/Address Book Plug-Ins\")") + ); + assert!( + policy + .policy + .contains("(subpath (param \"ADDRESSBOOK_DIR\"))") + ); + assert!(policy.policy.contains("com.apple.contactsd.persistence")); + assert!(policy.policy.contains("com.apple.accountsd.accountmanager")); + assert!(!policy.policy.contains("com.apple.securityd.xpc")); + assert!( + policy + .dir_params + .iter() + .any(|(key, _)| key == "ADDRESSBOOK_DIR") + ); +} + +#[test] +fn contacts_read_write_emit_write_clauses() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_contacts: MacOsContactsPermission::ReadWrite, + ..Default::default() + }); + + assert!(policy.policy.contains("(subpath \"/var/folders\")")); + assert!(policy.policy.contains("(subpath \"/private/var/folders\")")); + assert!(policy.policy.contains("com.apple.securityd.xpc")); +} + +#[test] +fn default_extensions_emit_preferences_read_only_policy() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()); + assert!(policy.policy.contains("(allow user-preference-read)")); + assert!(!policy.policy.contains("(allow user-preference-write)")); +} diff --git a/codex-rs/core/src/seatbelt_tests.rs b/codex-rs/core/src/seatbelt_tests.rs new file mode 100644 index 0000000000..9ac5eaa7b0 --- /dev/null +++ b/codex-rs/core/src/seatbelt_tests.rs @@ -0,0 +1,1058 @@ +use super::MACOS_SEATBELT_BASE_POLICY; +use super::ProxyPolicyInputs; +use super::UnixDomainSocketPolicy; +use super::create_seatbelt_command_args; +use super::create_seatbelt_command_args_for_policies_with_extensions; +use super::create_seatbelt_command_args_with_extensions; +use super::dynamic_network_policy; +use super::macos_dir_params; +use super::normalize_path_for_sandbox; +use super::unix_socket_dir_params; +use super::unix_socket_policy; +use crate::protocol::ReadOnlyAccess; +use crate::protocol::SandboxPolicy; +use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +use crate::seatbelt_permissions::MacOsAutomationPermission; +use crate::seatbelt_permissions::MacOsContactsPermission; +use crate::seatbelt_permissions::MacOsPreferencesPermission; +use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use tempfile::TempDir; + +fn assert_seatbelt_denied(stderr: &[u8], path: &Path) { + let stderr = String::from_utf8_lossy(stderr); + let expected = format!("bash: {}: Operation not permitted\n", path.display()); + assert!( + stderr == expected + || stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted"), + "unexpected stderr: {stderr}" + ); +} + +fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(Path::new(path)).expect("absolute path") +} + +fn seatbelt_policy_arg(args: &[String]) -> &str { + let policy_index = args + .iter() + .position(|arg| arg == "-p") + .expect("seatbelt args should include -p"); + args.get(policy_index + 1) + .expect("seatbelt args should include policy text") +} + +#[test] +fn base_policy_allows_node_cpu_sysctls() { + assert!( + MACOS_SEATBELT_BASE_POLICY.contains("(sysctl-name \"machdep.cpu.brand_string\")"), + "base policy must allow CPU brand lookup for os.cpus()" + ); + assert!( + MACOS_SEATBELT_BASE_POLICY.contains("(sysctl-name \"hw.model\")"), + "base policy must allow hardware model lookup for os.cpus()" + ); +} + +#[test] +fn create_seatbelt_args_routes_network_through_proxy_ports() { + let policy = dynamic_network_policy( + &SandboxPolicy::new_read_only_policy(), + false, + &ProxyPolicyInputs { + ports: vec![43128, 48081], + has_proxy_config: true, + allow_local_binding: false, + ..ProxyPolicyInputs::default() + }, + ); + + assert!( + policy.contains("(allow network-outbound (remote ip \"localhost:43128\"))"), + "expected HTTP proxy port allow rule in policy:\n{policy}" + ); + assert!( + policy.contains("(allow network-outbound (remote ip \"localhost:48081\"))"), + "expected SOCKS proxy port allow rule in policy:\n{policy}" + ); + assert!( + !policy.contains("\n(allow network-outbound)\n"), + "policy should not include blanket outbound allowance when proxy ports are present:\n{policy}" + ); + assert!( + !policy.contains("(allow network-bind (local ip \"localhost:*\"))"), + "policy should not allow loopback binding unless explicitly enabled:\n{policy}" + ); + assert!( + !policy.contains("(allow network-inbound (local ip \"localhost:*\"))"), + "policy should not allow loopback inbound unless explicitly enabled:\n{policy}" + ); +} + +#[test] +fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() { + let unreadable = absolute_path("/tmp/codex-unreadable"); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: crate::protocol::FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: unreadable }, + access: FileSystemAccessMode::None, + }, + ]); + + let args = create_seatbelt_command_args_for_policies_with_extensions( + vec!["/bin/true".to_string()], + &file_system_policy, + NetworkSandboxPolicy::Restricted, + Path::new("/"), + false, + None, + None, + ); + + let policy = seatbelt_policy_arg(&args); + assert!( + policy.contains("(require-not (subpath (param \"READABLE_ROOT_0_RO_0\")))"), + "expected read carveout in policy:\n{policy}" + ); + assert!( + policy.contains("(require-not (subpath (param \"WRITABLE_ROOT_0_RO_0\")))"), + "expected write carveout in policy:\n{policy}" + ); + assert!( + args.iter() + .any(|arg| arg == "-DREADABLE_ROOT_0_RO_0=/tmp/codex-unreadable"), + "expected read carveout parameter in args: {args:#?}" + ); + assert!( + args.iter() + .any(|arg| arg == "-DWRITABLE_ROOT_0_RO_0=/tmp/codex-unreadable"), + "expected write carveout parameter in args: {args:#?}" + ); +} + +#[test] +fn explicit_unreadable_paths_are_excluded_from_readable_roots() { + let root = absolute_path("/tmp/codex-readable"); + let unreadable = absolute_path("/tmp/codex-readable/private"); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: root }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: unreadable }, + access: FileSystemAccessMode::None, + }, + ]); + + let args = create_seatbelt_command_args_for_policies_with_extensions( + vec!["/bin/true".to_string()], + &file_system_policy, + NetworkSandboxPolicy::Restricted, + Path::new("/"), + false, + None, + None, + ); + + let policy = seatbelt_policy_arg(&args); + assert!( + policy.contains("(require-not (subpath (param \"READABLE_ROOT_0_RO_0\")))"), + "expected read carveout in policy:\n{policy}" + ); + assert!( + args.iter() + .any(|arg| arg == "-DREADABLE_ROOT_0=/tmp/codex-readable"), + "expected readable root parameter in args: {args:#?}" + ); + assert!( + args.iter() + .any(|arg| arg == "-DREADABLE_ROOT_0_RO_0=/tmp/codex-readable/private"), + "expected read carveout parameter in args: {args:#?}" + ); +} + +#[test] +fn seatbelt_args_include_macos_permission_extensions() { + let cwd = std::env::temp_dir(); + let args = create_seatbelt_command_args_with_extensions( + vec!["echo".to_string(), "ok".to_string()], + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + false, + None, + Some(&MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_launch_services: true, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::None, + }), + ); + let policy = &args[1]; + + assert!(policy.contains("(allow user-preference-write)")); + assert!(policy.contains("(appleevent-destination \"com.apple.Notes\")")); + assert!(policy.contains("com.apple.axserver")); + assert!(policy.contains("com.apple.CalendarAgent")); +} + +#[test] +fn bundle_id_automation_keeps_lsopen_denied() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("cwd"); + fs::create_dir_all(&cwd).expect("create cwd"); + + let args = create_seatbelt_command_args_with_extensions( + vec![ + "/usr/bin/python3".to_string(), + "-c".to_string(), + r#"import ctypes +import os +import sys +lib = ctypes.CDLL("/usr/lib/libsandbox.1.dylib") +lib.sandbox_check.restype = ctypes.c_int +allowed = lib.sandbox_check(os.getpid(), b"lsopen", 0) == 0 +sys.exit(0 if allowed else 13) +"# + .to_string(), + ], + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + false, + None, + Some(&MacOsSeatbeltProfileExtensions { + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + ..Default::default() + }), + ); + + let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) + .args(&args) + .current_dir(&cwd) + .output() + .expect("execute seatbelt command"); + + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted") { + return; + } + + assert_eq!( + Some(13), + output.status.code(), + "lsopen should remain denied even with bundle-scoped automation\nstdout: {}\nstderr: {stderr}", + String::from_utf8_lossy(&output.stdout), + ); +} + +#[test] +fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() { + let cwd = std::env::temp_dir(); + let args = create_seatbelt_command_args( + vec!["echo".to_string(), "ok".to_string()], + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + false, + None, + ); + let policy = &args[1]; + assert!(policy.contains("(allow user-preference-read)")); + assert!(!policy.contains("(allow user-preference-write)")); +} + +#[test] +fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("workspace"); + fs::create_dir_all(cwd.join("docs")).expect("create docs"); + let docs = AbsolutePathBuf::from_absolute_path(cwd.join("docs")).expect("absolute docs"); + let args = create_seatbelt_command_args( + vec!["/bin/true".to_string()], + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![docs.clone()], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }, + cwd.as_path(), + false, + None, + ); + + let docs_param = format!("-DWRITABLE_ROOT_0_RO_0={}", docs.as_path().display()); + assert!( + !seatbelt_policy_arg(&args).contains("WRITABLE_ROOT_0_RO_0"), + "legacy workspace-write readable roots under cwd should not become seatbelt carveouts:\n{args:#?}" + ); + assert!( + !args.iter().any(|arg| arg == &docs_param), + "unexpected seatbelt carveout parameter for redundant legacy readable root: {args:#?}" + ); +} + +#[test] +fn seatbelt_args_default_extension_profile_keeps_preferences_read_access() { + let cwd = std::env::temp_dir(); + let args = create_seatbelt_command_args_with_extensions( + vec!["echo".to_string(), "ok".to_string()], + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + false, + None, + Some(&MacOsSeatbeltProfileExtensions::default()), + ); + let policy = &args[1]; + assert!(!policy.contains("appleevent-send")); + assert!(!policy.contains("com.apple.axserver")); + assert!(!policy.contains("com.apple.CalendarAgent")); + assert!(policy.contains("(allow user-preference-read)")); + assert!(!policy.contains("user-preference-write")); +} + +#[test] +fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() { + let policy = dynamic_network_policy( + &SandboxPolicy::new_read_only_policy(), + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: true, + ..ProxyPolicyInputs::default() + }, + ); + + assert!( + policy.contains("(allow network-bind (local ip \"localhost:*\"))"), + "policy should allow loopback local binding when explicitly enabled:\n{policy}" + ); + assert!( + policy.contains("(allow network-inbound (local ip \"localhost:*\"))"), + "policy should allow loopback inbound when explicitly enabled:\n{policy}" + ); + assert!( + policy.contains("(allow network-outbound (remote ip \"localhost:*\"))"), + "policy should allow loopback outbound when explicitly enabled:\n{policy}" + ); + assert!( + !policy.contains("\n(allow network-outbound)\n"), + "policy should keep proxy-routed behavior without blanket outbound allowance:\n{policy}" + ); +} + +#[test] +fn dynamic_network_policy_preserves_restricted_policy_when_proxy_config_without_ports() { + let policy = dynamic_network_policy( + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + false, + &ProxyPolicyInputs { + ports: vec![], + has_proxy_config: true, + allow_local_binding: false, + ..ProxyPolicyInputs::default() + }, + ); + + assert!( + policy.contains("(socket-domain AF_SYSTEM)"), + "policy should keep the restricted network profile when proxy config is present without ports:\n{policy}" + ); + assert!( + !policy.contains("\n(allow network-outbound)\n"), + "policy should not include blanket outbound allowance when proxy config is present without ports:\n{policy}" + ); + assert!( + !policy.contains("(allow network-outbound (remote ip \"localhost:"), + "policy should not include proxy port allowance when proxy config is present without ports:\n{policy}" + ); +} + +#[test] +fn dynamic_network_policy_preserves_restricted_policy_for_managed_network_without_proxy_config() { + let policy = dynamic_network_policy( + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + true, + &ProxyPolicyInputs { + ports: vec![], + has_proxy_config: false, + allow_local_binding: false, + ..ProxyPolicyInputs::default() + }, + ); + + assert!( + policy.contains("(socket-domain AF_SYSTEM)"), + "policy should keep the restricted network profile when managed network is active without proxy endpoints:\n{policy}" + ); + assert!( + !policy.contains("\n(allow network-outbound)\n"), + "policy should not include blanket outbound allowance when managed network is active without proxy endpoints:\n{policy}" + ); +} + +#[test] +fn create_seatbelt_args_allowlists_unix_socket_paths() { + let policy = dynamic_network_policy( + &SandboxPolicy::new_read_only_policy(), + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: false, + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![absolute_path("/tmp/example.sock")], + }, + }, + ); + + assert!( + policy.contains("(allow system-socket (socket-domain AF_UNIX))"), + "policy should allow AF_UNIX socket creation for configured unix sockets:\n{policy}" + ); + assert!( + policy.contains( + "(allow network-bind (local unix-socket (subpath (param \"UNIX_SOCKET_PATH_0\"))))" + ), + "policy should allow binding explicitly configured unix sockets:\n{policy}" + ); + assert!( + policy.contains( + "(allow network-outbound (remote unix-socket (subpath (param \"UNIX_SOCKET_PATH_0\"))))" + ), + "policy should allow connecting to explicitly configured unix sockets:\n{policy}" + ); + assert!( + !policy.contains("(allow network* (subpath"), + "policy should no longer use the generic subpath unix-socket rules:\n{policy}" + ); +} + +#[test] +fn unix_socket_policy_non_empty_output_is_newline_terminated() { + let allowlist_policy = unix_socket_policy(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![absolute_path("/tmp/example.sock")], + }, + ..ProxyPolicyInputs::default() + }); + assert!( + allowlist_policy.ends_with('\n'), + "allowlist unix socket policy should end with a newline:\n{allowlist_policy}" + ); + + let allow_all_policy = unix_socket_policy(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::AllowAll, + ..ProxyPolicyInputs::default() + }); + assert!( + allow_all_policy.ends_with('\n'), + "allow-all unix socket policy should end with a newline:\n{allow_all_policy}" + ); +} + +#[test] +fn unix_socket_dir_params_use_stable_param_names() { + let params = unix_socket_dir_params(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![ + absolute_path("/tmp/b.sock"), + absolute_path("/tmp/a.sock"), + absolute_path("/tmp/a.sock"), + ], + }, + ..ProxyPolicyInputs::default() + }); + + assert_eq!( + params, + vec![ + ( + "UNIX_SOCKET_PATH_0".to_string(), + PathBuf::from("/tmp/a.sock") + ), + ( + "UNIX_SOCKET_PATH_1".to_string(), + PathBuf::from("/tmp/b.sock") + ), + ] + ); +} + +#[test] +fn normalize_path_for_sandbox_rejects_relative_paths() { + assert_eq!(normalize_path_for_sandbox(Path::new("relative.sock")), None); +} + +#[test] +fn create_seatbelt_args_allows_all_unix_sockets_when_enabled() { + let policy = dynamic_network_policy( + &SandboxPolicy::new_read_only_policy(), + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: false, + unix_domain_socket_policy: UnixDomainSocketPolicy::AllowAll, + }, + ); + + assert!( + policy.contains("(allow system-socket (socket-domain AF_UNIX))"), + "policy should allow AF_UNIX socket creation when unix sockets are enabled:\n{policy}" + ); + assert!( + policy.contains("(allow network-bind (local unix-socket))"), + "policy should allow binding unix sockets when enabled:\n{policy}" + ); + assert!( + policy.contains("(allow network-outbound (remote unix-socket))"), + "policy should allow connecting to unix sockets when enabled:\n{policy}" + ); + assert!( + !policy.contains("(allow network* (subpath"), + "policy should no longer use the generic subpath unix-socket rules:\n{policy}" + ); +} + +#[test] +fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() { + let policy = dynamic_network_policy( + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: false, + ..ProxyPolicyInputs::default() + }, + ); + + assert!( + policy.contains("(allow network-outbound (remote ip \"localhost:43128\"))"), + "expected proxy endpoint allow rule in policy:\n{policy}" + ); + assert!( + !policy.contains("\n(allow network-outbound)\n"), + "policy should not include blanket outbound allowance when proxy is configured:\n{policy}" + ); + assert!( + !policy.contains("\n(allow network-inbound)\n"), + "policy should not include blanket inbound allowance when proxy is configured:\n{policy}" + ); +} + +#[test] +fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { + // Create a temporary workspace with two writable roots: one containing + // top-level .git and .codex directories and one without them. + let tmp = TempDir::new().expect("tempdir"); + let PopulatedTmp { + vulnerable_root, + vulnerable_root_canonical, + dot_git_canonical, + dot_codex_canonical, + empty_root, + empty_root_canonical, + } = populate_tmpdir(tmp.path()); + let cwd = tmp.path().join("cwd"); + fs::create_dir_all(&cwd).expect("create cwd"); + + // Build a policy that only includes the two test roots as writable and + // does not automatically include defaults TMPDIR or /tmp. + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![vulnerable_root, empty_root] + .into_iter() + .map(|p| p.try_into().unwrap()) + .collect(), + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + // Create the Seatbelt command to wrap a shell command that tries to + // write to .codex/config.toml in the vulnerable root. + let shell_command: Vec = [ + "bash", + "-c", + "echo 'sandbox_mode = \"danger-full-access\"' > \"$1\"", + "bash", + dot_codex_canonical + .join("config.toml") + .to_string_lossy() + .as_ref(), + ] + .iter() + .map(std::string::ToString::to_string) + .collect(); + let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None); + + // Build the expected policy text using a raw string for readability. + // Note that the policy includes: + // - the base policy, + // - read-only access to the filesystem, + // - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2. + let expected_policy = format!( + r#"{MACOS_SEATBELT_BASE_POLICY} +; allow read-only file operations +(allow file-read*) +(allow file-write* +(subpath (param "WRITABLE_ROOT_0")) (require-all (subpath (param "WRITABLE_ROOT_1")) (require-not (subpath (param "WRITABLE_ROOT_1_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_1_RO_1"))) ) (subpath (param "WRITABLE_ROOT_2")) +) + +; macOS permission profile extensions +(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) +(allow mach-lookup + (global-name "com.apple.cfprefsd.daemon") + (global-name "com.apple.cfprefsd.agent") + (local-name "com.apple.cfprefsd.agent")) +(allow user-preference-read) +"#, + ); + + assert_eq!(seatbelt_policy_arg(&args), expected_policy); + + let expected_definitions = [ + format!( + "-DWRITABLE_ROOT_0={}", + cwd.canonicalize() + .expect("canonicalize cwd") + .to_string_lossy() + ), + format!( + "-DWRITABLE_ROOT_1={}", + vulnerable_root_canonical.to_string_lossy() + ), + format!( + "-DWRITABLE_ROOT_1_RO_0={}", + dot_git_canonical.to_string_lossy() + ), + format!( + "-DWRITABLE_ROOT_1_RO_1={}", + dot_codex_canonical.to_string_lossy() + ), + format!( + "-DWRITABLE_ROOT_2={}", + empty_root_canonical.to_string_lossy() + ), + ]; + for expected_definition in expected_definitions { + assert!( + args.contains(&expected_definition), + "expected definition arg `{expected_definition}` in {args:#?}" + ); + } + for (key, value) in macos_dir_params() { + let expected_definition = format!("-D{key}={}", value.to_string_lossy()); + assert!( + args.contains(&expected_definition), + "expected definition arg `{expected_definition}` in {args:#?}" + ); + } + + let command_index = args + .iter() + .position(|arg| arg == "--") + .expect("seatbelt args should include command separator"); + assert_eq!(args[command_index + 1..], shell_command); + + // Verify that .codex/config.toml cannot be modified under the generated + // Seatbelt policy. + let config_toml = dot_codex_canonical.join("config.toml"); + let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) + .args(&args) + .current_dir(&cwd) + .output() + .expect("execute seatbelt command"); + assert_eq!( + "sandbox_mode = \"read-only\"\n", + String::from_utf8_lossy(&fs::read(&config_toml).expect("read config.toml")), + "config.toml should contain its original contents because it should not have been modified" + ); + assert!( + !output.status.success(), + "command to write {} should fail under seatbelt", + &config_toml.display() + ); + assert_seatbelt_denied(&output.stderr, &config_toml); + + // Create a similar Seatbelt command that tries to write to a file in + // the .git folder, which should also be blocked. + let pre_commit_hook = dot_git_canonical.join("hooks").join("pre-commit"); + let shell_command_git: Vec = [ + "bash", + "-c", + "echo 'pwned!' > \"$1\"", + "bash", + pre_commit_hook.to_string_lossy().as_ref(), + ] + .iter() + .map(std::string::ToString::to_string) + .collect(); + let write_hooks_file_args = + create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None); + let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) + .args(&write_hooks_file_args) + .current_dir(&cwd) + .output() + .expect("execute seatbelt command"); + assert!( + !fs::exists(&pre_commit_hook).expect("exists pre-commit hook"), + "{} should not exist because it should not have been created", + pre_commit_hook.display() + ); + assert!( + !output.status.success(), + "command to write {} should fail under seatbelt", + &pre_commit_hook.display() + ); + assert_seatbelt_denied(&output.stderr, &pre_commit_hook); + + // Verify that writing a file to the folder containing .git and .codex is allowed. + let allowed_file = vulnerable_root_canonical.join("allowed.txt"); + let shell_command_allowed: Vec = [ + "bash", + "-c", + "echo 'this is allowed' > \"$1\"", + "bash", + allowed_file.to_string_lossy().as_ref(), + ] + .iter() + .map(std::string::ToString::to_string) + .collect(); + let write_allowed_file_args = + create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None); + let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) + .args(&write_allowed_file_args) + .current_dir(&cwd) + .output() + .expect("execute seatbelt command"); + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() + && stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted") + { + return; + } + assert!( + output.status.success(), + "command to write {} should succeed under seatbelt", + &allowed_file.display() + ); + assert_eq!( + "this is allowed\n", + String::from_utf8_lossy(&fs::read(&allowed_file).expect("read allowed.txt")), + "{} should contain the written text", + allowed_file.display() + ); +} + +#[test] +fn create_seatbelt_args_with_read_only_git_pointer_file() { + let tmp = TempDir::new().expect("tempdir"); + let worktree_root = tmp.path().join("worktree_root"); + fs::create_dir_all(&worktree_root).expect("create worktree_root"); + let gitdir = worktree_root.join("actual-gitdir"); + fs::create_dir_all(&gitdir).expect("create gitdir"); + let gitdir_config = gitdir.join("config"); + let gitdir_config_contents = "[core]\n"; + fs::write(&gitdir_config, gitdir_config_contents).expect("write gitdir config"); + + let dot_git = worktree_root.join(".git"); + let dot_git_contents = format!("gitdir: {}\n", gitdir.to_string_lossy()); + fs::write(&dot_git, &dot_git_contents).expect("write .git pointer"); + + let cwd = tmp.path().join("cwd"); + fs::create_dir_all(&cwd).expect("create cwd"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![worktree_root.try_into().expect("worktree_root is absolute")], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + let shell_command: Vec = [ + "bash", + "-c", + "echo 'pwned!' > \"$1\"", + "bash", + dot_git.to_string_lossy().as_ref(), + ] + .iter() + .map(std::string::ToString::to_string) + .collect(); + let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None); + + let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) + .args(&args) + .current_dir(&cwd) + .output() + .expect("execute seatbelt command"); + + assert_eq!( + dot_git_contents, + String::from_utf8_lossy(&fs::read(&dot_git).expect("read .git pointer")), + ".git pointer file should not be modified under seatbelt" + ); + assert!( + !output.status.success(), + "command to write {} should fail under seatbelt", + dot_git.display() + ); + assert_seatbelt_denied(&output.stderr, &dot_git); + + let shell_command_gitdir: Vec = [ + "bash", + "-c", + "echo 'pwned!' > \"$1\"", + "bash", + gitdir_config.to_string_lossy().as_ref(), + ] + .iter() + .map(std::string::ToString::to_string) + .collect(); + let gitdir_args = + create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None); + let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) + .args(&gitdir_args) + .current_dir(&cwd) + .output() + .expect("execute seatbelt command"); + + assert_eq!( + gitdir_config_contents, + String::from_utf8_lossy(&fs::read(&gitdir_config).expect("read gitdir config")), + "gitdir config should contain its original contents because it should not have been modified" + ); + assert!( + !output.status.success(), + "command to write {} should fail under seatbelt", + gitdir_config.display() + ); + assert_seatbelt_denied(&output.stderr, &gitdir_config); +} + +#[test] +fn create_seatbelt_args_for_cwd_as_git_repo() { + // Create a temporary workspace with two writable roots: one containing + // top-level .git and .codex directories and one without them. + let tmp = TempDir::new().expect("tempdir"); + let PopulatedTmp { + vulnerable_root, + vulnerable_root_canonical, + dot_git_canonical, + dot_codex_canonical, + .. + } = populate_tmpdir(tmp.path()); + + // Build a policy that does not specify any writable_roots, but does + // use the default ones (cwd and TMPDIR) and verifies the `.git` and + // `.codex` checks are done properly for cwd. + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + let shell_command: Vec = [ + "bash", + "-c", + "echo 'sandbox_mode = \"danger-full-access\"' > \"$1\"", + "bash", + dot_codex_canonical + .join("config.toml") + .to_string_lossy() + .as_ref(), + ] + .iter() + .map(std::string::ToString::to_string) + .collect(); + let args = create_seatbelt_command_args( + shell_command.clone(), + &policy, + vulnerable_root.as_path(), + false, + None, + ); + + let tmpdir_env_var = std::env::var("TMPDIR") + .ok() + .map(PathBuf::from) + .and_then(|p| p.canonicalize().ok()) + .map(|p| p.to_string_lossy().to_string()); + + let tempdir_policy_entry = if tmpdir_env_var.is_some() { + r#" (subpath (param "WRITABLE_ROOT_2"))"# + } else { + "" + }; + + // Build the expected policy text using a raw string for readability. + // Note that the policy includes: + // - the base policy, + // - read-only access to the filesystem, + // - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2. + let expected_policy = format!( + r#"{MACOS_SEATBELT_BASE_POLICY} +; allow read-only file operations +(allow file-read*) +(allow file-write* +(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry} +) + +; macOS permission profile extensions +(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) +(allow mach-lookup + (global-name "com.apple.cfprefsd.daemon") + (global-name "com.apple.cfprefsd.agent") + (local-name "com.apple.cfprefsd.agent")) +(allow user-preference-read) +"#, + ); + + let mut expected_args = vec![ + "-p".to_string(), + expected_policy, + format!( + "-DWRITABLE_ROOT_0={}", + vulnerable_root_canonical.to_string_lossy() + ), + format!( + "-DWRITABLE_ROOT_0_RO_0={}", + dot_git_canonical.to_string_lossy() + ), + format!( + "-DWRITABLE_ROOT_0_RO_1={}", + dot_codex_canonical.to_string_lossy() + ), + format!( + "-DWRITABLE_ROOT_1={}", + PathBuf::from("/tmp") + .canonicalize() + .expect("canonicalize /tmp") + .to_string_lossy() + ), + ]; + + if let Some(p) = tmpdir_env_var { + expected_args.push(format!("-DWRITABLE_ROOT_2={p}")); + } + + expected_args.extend( + macos_dir_params() + .into_iter() + .map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())), + ); + + expected_args.push("--".to_string()); + expected_args.extend(shell_command); + + assert_eq!(expected_args, args); +} + +struct PopulatedTmp { + /// Path containing a .git and .codex subfolder. + /// For the purposes of this test, we consider this a "vulnerable" root + /// because a bad actor could write to .git/hooks/pre-commit so an + /// unsuspecting user would run code as privileged the next time they + /// ran `git commit` themselves, or modified .codex/config.toml to + /// contain `sandbox_mode = "danger-full-access"` so the agent would + /// have full privileges the next time it ran in that repo. + vulnerable_root: PathBuf, + vulnerable_root_canonical: PathBuf, + dot_git_canonical: PathBuf, + dot_codex_canonical: PathBuf, + + /// Path without .git or .codex subfolders. + empty_root: PathBuf, + /// Canonicalized version of `empty_root`. + empty_root_canonical: PathBuf, +} + +fn populate_tmpdir(tmp: &Path) -> PopulatedTmp { + let vulnerable_root = tmp.join("vulnerable_root"); + fs::create_dir_all(&vulnerable_root).expect("create vulnerable_root"); + + // TODO(mbolin): Should also support the case where `.git` is a file + // with a gitdir: ... line. + Command::new("git") + .arg("init") + .arg(".") + .current_dir(&vulnerable_root) + .output() + .expect("git init ."); + + fs::create_dir_all(vulnerable_root.join(".codex")).expect("create .codex"); + fs::write( + vulnerable_root.join(".codex").join("config.toml"), + "sandbox_mode = \"read-only\"\n", + ) + .expect("write .codex/config.toml"); + + let empty_root = tmp.join("empty_root"); + fs::create_dir_all(&empty_root).expect("create empty_root"); + + // Ensure we have canonical paths for -D parameter matching. + let vulnerable_root_canonical = vulnerable_root + .canonicalize() + .expect("canonicalize vulnerable_root"); + let dot_git_canonical = vulnerable_root_canonical.join(".git"); + let dot_codex_canonical = vulnerable_root_canonical.join(".codex"); + let empty_root_canonical = empty_root.canonicalize().expect("canonicalize empty_root"); + PopulatedTmp { + vulnerable_root, + vulnerable_root_canonical, + dot_git_canonical, + dot_codex_canonical, + empty_root, + empty_root_canonical, + } +} diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 4cd728992e..dba595c1ec 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -381,173 +381,5 @@ mod detect_shell_type_tests { #[cfg(test)] #[cfg(unix)] -mod tests { - use super::*; - use std::path::PathBuf; - use std::process::Command; - - #[test] - #[cfg(target_os = "macos")] - fn detects_zsh() { - let zsh_shell = get_shell(ShellType::Zsh, None).unwrap(); - - let shell_path = zsh_shell.shell_path; - - assert_eq!(shell_path, std::path::Path::new("/bin/zsh")); - } - - #[test] - #[cfg(target_os = "macos")] - fn fish_fallback_to_zsh() { - let zsh_shell = default_user_shell_from_path(Some(PathBuf::from("/bin/fish"))); - - let shell_path = zsh_shell.shell_path; - - assert_eq!(shell_path, std::path::Path::new("/bin/zsh")); - } - - #[test] - fn detects_bash() { - let bash_shell = get_shell(ShellType::Bash, None).unwrap(); - let shell_path = bash_shell.shell_path; - - assert!( - shell_path.file_name().and_then(|name| name.to_str()) == Some("bash"), - "shell path: {shell_path:?}", - ); - } - - #[test] - fn detects_sh() { - let sh_shell = get_shell(ShellType::Sh, None).unwrap(); - let shell_path = sh_shell.shell_path; - assert!( - shell_path.file_name().and_then(|name| name.to_str()) == Some("sh"), - "shell path: {shell_path:?}", - ); - } - - #[test] - fn can_run_on_shell_test() { - let cmd = "echo \"Works\""; - if cfg!(windows) { - assert!(shell_works( - get_shell(ShellType::PowerShell, None), - "Out-String 'Works'", - true, - )); - assert!(shell_works(get_shell(ShellType::Cmd, None), cmd, true,)); - assert!(shell_works(Some(ultimate_fallback_shell()), cmd, true)); - } else { - assert!(shell_works(Some(ultimate_fallback_shell()), cmd, true)); - assert!(shell_works(get_shell(ShellType::Zsh, None), cmd, false)); - assert!(shell_works(get_shell(ShellType::Bash, None), cmd, true)); - assert!(shell_works(get_shell(ShellType::Sh, None), cmd, true)); - } - } - - fn shell_works(shell: Option, command: &str, required: bool) -> bool { - if let Some(shell) = shell { - let args = shell.derive_exec_args(command, false); - let output = Command::new(args[0].clone()) - .args(&args[1..]) - .output() - .unwrap(); - assert!(output.status.success()); - assert!(String::from_utf8_lossy(&output.stdout).contains("Works")); - true - } else { - !required - } - } - - #[test] - fn derive_exec_args() { - let test_bash_shell = Shell { - shell_type: ShellType::Bash, - shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: empty_shell_snapshot_receiver(), - }; - assert_eq!( - test_bash_shell.derive_exec_args("echo hello", false), - vec!["/bin/bash", "-c", "echo hello"] - ); - assert_eq!( - test_bash_shell.derive_exec_args("echo hello", true), - vec!["/bin/bash", "-lc", "echo hello"] - ); - - let test_zsh_shell = Shell { - shell_type: ShellType::Zsh, - shell_path: PathBuf::from("/bin/zsh"), - shell_snapshot: empty_shell_snapshot_receiver(), - }; - assert_eq!( - test_zsh_shell.derive_exec_args("echo hello", false), - vec!["/bin/zsh", "-c", "echo hello"] - ); - assert_eq!( - test_zsh_shell.derive_exec_args("echo hello", true), - vec!["/bin/zsh", "-lc", "echo hello"] - ); - - let test_powershell_shell = Shell { - shell_type: ShellType::PowerShell, - shell_path: PathBuf::from("pwsh.exe"), - shell_snapshot: empty_shell_snapshot_receiver(), - }; - assert_eq!( - test_powershell_shell.derive_exec_args("echo hello", false), - vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"] - ); - assert_eq!( - test_powershell_shell.derive_exec_args("echo hello", true), - vec!["pwsh.exe", "-Command", "echo hello"] - ); - } - - #[tokio::test] - async fn test_current_shell_detects_zsh() { - let shell = Command::new("sh") - .arg("-c") - .arg("echo $SHELL") - .output() - .unwrap(); - - let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string(); - if shell_path.ends_with("/zsh") { - assert_eq!( - default_user_shell(), - Shell { - shell_type: ShellType::Zsh, - shell_path: PathBuf::from(shell_path), - shell_snapshot: empty_shell_snapshot_receiver(), - } - ); - } - } - - #[tokio::test] - async fn detects_powershell_as_default() { - if !cfg!(windows) { - return; - } - - let powershell_shell = default_user_shell(); - let shell_path = powershell_shell.shell_path; - - assert!(shell_path.ends_with("pwsh.exe") || shell_path.ends_with("powershell.exe")); - } - - #[test] - fn finds_powershell() { - if !cfg!(windows) { - return; - } - - let powershell_shell = get_shell(ShellType::PowerShell, None).unwrap(); - let shell_path = powershell_shell.shell_path; - - assert!(shell_path.ends_with("pwsh.exe") || shell_path.ends_with("powershell.exe")); - } -} +#[path = "shell_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index c10a332245..2c6c9b5295 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -544,425 +544,5 @@ async fn remove_snapshot_file(path: &Path) { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - #[cfg(unix)] - use std::os::unix::ffi::OsStrExt; - #[cfg(unix)] - use std::process::Command; - #[cfg(target_os = "linux")] - use std::process::Command as StdCommand; - - use tempfile::tempdir; - - #[cfg(unix)] - struct BlockingStdinPipe { - original: i32, - write_end: i32, - } - - #[cfg(unix)] - impl BlockingStdinPipe { - fn install() -> Result { - let mut fds = [0i32; 2]; - if unsafe { libc::pipe(fds.as_mut_ptr()) } == -1 { - return Err(std::io::Error::last_os_error()).context("create stdin pipe"); - } - - let original = unsafe { libc::dup(libc::STDIN_FILENO) }; - if original == -1 { - let err = std::io::Error::last_os_error(); - unsafe { - libc::close(fds[0]); - libc::close(fds[1]); - } - return Err(err).context("dup stdin"); - } - - if unsafe { libc::dup2(fds[0], libc::STDIN_FILENO) } == -1 { - let err = std::io::Error::last_os_error(); - unsafe { - libc::close(fds[0]); - libc::close(fds[1]); - libc::close(original); - } - return Err(err).context("replace stdin"); - } - - unsafe { - libc::close(fds[0]); - } - - Ok(Self { - original, - write_end: fds[1], - }) - } - } - - #[cfg(unix)] - impl Drop for BlockingStdinPipe { - fn drop(&mut self) { - unsafe { - libc::dup2(self.original, libc::STDIN_FILENO); - libc::close(self.original); - libc::close(self.write_end); - } - } - } - - #[cfg(not(target_os = "windows"))] - fn assert_posix_snapshot_sections(snapshot: &str) { - assert!(snapshot.contains("# Snapshot file")); - assert!(snapshot.contains("aliases ")); - assert!(snapshot.contains("exports ")); - assert!( - snapshot.contains("PATH"), - "snapshot should capture a PATH export" - ); - assert!(snapshot.contains("setopts ")); - } - - async fn get_snapshot(shell_type: ShellType) -> Result { - let dir = tempdir()?; - let path = dir.path().join("snapshot.sh"); - write_shell_snapshot(shell_type, &path, dir.path()).await?; - let content = fs::read_to_string(&path).await?; - Ok(content) - } - - #[test] - fn strip_snapshot_preamble_removes_leading_output() { - let snapshot = "noise\n# Snapshot file\nexport PATH=/bin\n"; - let cleaned = strip_snapshot_preamble(snapshot).expect("snapshot marker exists"); - assert_eq!(cleaned, "# Snapshot file\nexport PATH=/bin\n"); - } - - #[test] - fn strip_snapshot_preamble_requires_marker() { - let result = strip_snapshot_preamble("missing header"); - assert!(result.is_err()); - } - - #[cfg(unix)] - #[test] - fn bash_snapshot_filters_invalid_exports() -> Result<()> { - let output = Command::new("/bin/bash") - .arg("-c") - .arg(bash_snapshot_script()) - .env("BASH_ENV", "/dev/null") - .env("VALID_NAME", "ok") - .env("PWD", "/tmp/stale") - .env("NEXTEST_BIN_EXE_codex-write-config-schema", "/path/to/bin") - .env("BAD-NAME", "broken") - .output()?; - - assert!(output.status.success()); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("VALID_NAME")); - assert!(!stdout.contains("PWD=/tmp/stale")); - assert!(!stdout.contains("NEXTEST_BIN_EXE_codex-write-config-schema")); - assert!(!stdout.contains("BAD-NAME")); - - Ok(()) - } - - #[cfg(unix)] - #[test] - fn bash_snapshot_preserves_multiline_exports() -> Result<()> { - let multiline_cert = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----"; - let output = Command::new("/bin/bash") - .arg("-c") - .arg(bash_snapshot_script()) - .env("BASH_ENV", "/dev/null") - .env("MULTILINE_CERT", multiline_cert) - .output()?; - - assert!(output.status.success()); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("MULTILINE_CERT=") || stdout.contains("MULTILINE_CERT"), - "snapshot should include the multiline export name" - ); - - let dir = tempdir()?; - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write(&snapshot_path, stdout.as_bytes())?; - - let validate = Command::new("/bin/bash") - .arg("-c") - .arg("set -e; . \"$1\"") - .arg("bash") - .arg(&snapshot_path) - .env("BASH_ENV", "/dev/null") - .output()?; - - assert!( - validate.status.success(), - "snapshot validation failed: {}", - String::from_utf8_lossy(&validate.stderr) - ); - - Ok(()) - } - - #[cfg(unix)] - #[tokio::test] - async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> { - let dir = tempdir()?; - let shell = Shell { - shell_type: ShellType::Bash, - shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }; - - let snapshot = ShellSnapshot::try_new(dir.path(), ThreadId::new(), dir.path(), &shell) - .await - .expect("snapshot should be created"); - let path = snapshot.path.clone(); - assert!(path.exists()); - assert_eq!(snapshot.cwd, dir.path().to_path_buf()); - - drop(snapshot); - - assert!(!path.exists()); - - Ok(()) - } - - #[cfg(unix)] - #[tokio::test] - async fn snapshot_shell_does_not_inherit_stdin() -> Result<()> { - let _stdin_guard = BlockingStdinPipe::install()?; - - let dir = tempdir()?; - let home = dir.path(); - let read_status_path = home.join("stdin-read-status"); - let read_status_display = read_status_path.display(); - // Persist the startup `read` exit status so the test can assert whether - // bash saw EOF on stdin after the snapshot process exits. - let bashrc = - format!("read -t 1 -r ignored\nprintf '%s' \"$?\" > \"{read_status_display}\"\n"); - fs::write(home.join(".bashrc"), bashrc).await?; - - let shell = Shell { - shell_type: ShellType::Bash, - shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }; - - let home_display = home.display(); - let script = format!( - "HOME=\"{home_display}\"; export HOME; {}", - bash_snapshot_script() - ); - let output = run_script_with_timeout(&shell, &script, Duration::from_secs(2), true, home) - .await - .context("run snapshot command")?; - let read_status = fs::read_to_string(&read_status_path) - .await - .context("read stdin probe status")?; - - assert_eq!( - read_status, "1", - "expected shell startup read to see EOF on stdin; status={read_status:?}" - ); - - assert!( - output.contains("# Snapshot file"), - "expected snapshot marker in output; output={output:?}" - ); - - Ok(()) - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn timed_out_snapshot_shell_is_terminated() -> Result<()> { - use std::process::Stdio; - use tokio::time::Duration as TokioDuration; - use tokio::time::Instant; - use tokio::time::sleep; - - let dir = tempdir()?; - let pid_path = dir.path().join("pid"); - let script = format!("echo $$ > \"{}\"; sleep 30", pid_path.display()); - - let shell = Shell { - shell_type: ShellType::Sh, - shell_path: PathBuf::from("/bin/sh"), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }; - - let err = - run_script_with_timeout(&shell, &script, Duration::from_secs(1), true, dir.path()) - .await - .expect_err("snapshot shell should time out"); - assert!( - err.to_string().contains("timed out"), - "expected timeout error, got {err:?}" - ); - - let pid = fs::read_to_string(&pid_path) - .await - .expect("snapshot shell writes its pid before timing out") - .trim() - .parse::()?; - - let deadline = Instant::now() + TokioDuration::from_secs(1); - loop { - let kill_status = StdCommand::new("kill") - .arg("-0") - .arg(pid.to_string()) - .stderr(Stdio::null()) - .stdout(Stdio::null()) - .status()?; - if !kill_status.success() { - break; - } - if Instant::now() >= deadline { - panic!("timed out snapshot shell is still alive after grace period"); - } - sleep(TokioDuration::from_millis(50)).await; - } - - Ok(()) - } - - #[cfg(target_os = "macos")] - #[tokio::test] - async fn macos_zsh_snapshot_includes_sections() -> Result<()> { - let snapshot = get_snapshot(ShellType::Zsh).await?; - assert_posix_snapshot_sections(&snapshot); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn linux_bash_snapshot_includes_sections() -> Result<()> { - let snapshot = get_snapshot(ShellType::Bash).await?; - assert_posix_snapshot_sections(&snapshot); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn linux_sh_snapshot_includes_sections() -> Result<()> { - let snapshot = get_snapshot(ShellType::Sh).await?; - assert_posix_snapshot_sections(&snapshot); - Ok(()) - } - - #[cfg(target_os = "windows")] - #[ignore] - #[tokio::test] - async fn windows_powershell_snapshot_includes_sections() -> Result<()> { - let snapshot = get_snapshot(ShellType::PowerShell).await?; - assert!(snapshot.contains("# Snapshot file")); - assert!(snapshot.contains("aliases ")); - assert!(snapshot.contains("exports ")); - Ok(()) - } - - async fn write_rollout_stub(codex_home: &Path, session_id: ThreadId) -> Result { - let dir = codex_home - .join("sessions") - .join("2025") - .join("01") - .join("01"); - fs::create_dir_all(&dir).await?; - let path = dir.join(format!("rollout-2025-01-01T00-00-00-{session_id}.jsonl")); - fs::write(&path, "").await?; - Ok(path) - } - - #[tokio::test] - async fn cleanup_stale_snapshots_removes_orphans_and_keeps_live() -> Result<()> { - let dir = tempdir()?; - let codex_home = dir.path(); - let snapshot_dir = codex_home.join(SNAPSHOT_DIR); - fs::create_dir_all(&snapshot_dir).await?; - - let live_session = ThreadId::new(); - let orphan_session = ThreadId::new(); - let live_snapshot = snapshot_dir.join(format!("{live_session}.sh")); - let orphan_snapshot = snapshot_dir.join(format!("{orphan_session}.sh")); - let invalid_snapshot = snapshot_dir.join("not-a-snapshot.txt"); - - write_rollout_stub(codex_home, live_session).await?; - fs::write(&live_snapshot, "live").await?; - fs::write(&orphan_snapshot, "orphan").await?; - fs::write(&invalid_snapshot, "invalid").await?; - - cleanup_stale_snapshots(codex_home, ThreadId::new()).await?; - - assert_eq!(live_snapshot.exists(), true); - assert_eq!(orphan_snapshot.exists(), false); - assert_eq!(invalid_snapshot.exists(), false); - Ok(()) - } - - #[cfg(unix)] - #[tokio::test] - async fn cleanup_stale_snapshots_removes_stale_rollouts() -> Result<()> { - let dir = tempdir()?; - let codex_home = dir.path(); - let snapshot_dir = codex_home.join(SNAPSHOT_DIR); - fs::create_dir_all(&snapshot_dir).await?; - - let stale_session = ThreadId::new(); - let stale_snapshot = snapshot_dir.join(format!("{stale_session}.sh")); - let rollout_path = write_rollout_stub(codex_home, stale_session).await?; - fs::write(&stale_snapshot, "stale").await?; - - set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?; - - cleanup_stale_snapshots(codex_home, ThreadId::new()).await?; - - assert_eq!(stale_snapshot.exists(), false); - Ok(()) - } - - #[cfg(unix)] - #[tokio::test] - async fn cleanup_stale_snapshots_skips_active_session() -> Result<()> { - let dir = tempdir()?; - let codex_home = dir.path(); - let snapshot_dir = codex_home.join(SNAPSHOT_DIR); - fs::create_dir_all(&snapshot_dir).await?; - - let active_session = ThreadId::new(); - let active_snapshot = snapshot_dir.join(format!("{active_session}.sh")); - let rollout_path = write_rollout_stub(codex_home, active_session).await?; - fs::write(&active_snapshot, "active").await?; - - set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?; - - cleanup_stale_snapshots(codex_home, active_session).await?; - - assert_eq!(active_snapshot.exists(), true); - Ok(()) - } - - #[cfg(unix)] - fn set_file_mtime(path: &Path, age: Duration) -> Result<()> { - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH)? - .as_secs() - .saturating_sub(age.as_secs()); - let tv_sec = now - .try_into() - .map_err(|_| anyhow!("Snapshot mtime is out of range for libc::timespec"))?; - let ts = libc::timespec { tv_sec, tv_nsec: 0 }; - let times = [ts, ts]; - let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())?; - let result = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) }; - if result != 0 { - return Err(std::io::Error::last_os_error().into()); - } - Ok(()) - } -} +#[path = "shell_snapshot_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/shell_snapshot_tests.rs b/codex-rs/core/src/shell_snapshot_tests.rs new file mode 100644 index 0000000000..558a40e2e2 --- /dev/null +++ b/codex-rs/core/src/shell_snapshot_tests.rs @@ -0,0 +1,418 @@ +use super::*; +use pretty_assertions::assert_eq; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::process::Command; +#[cfg(target_os = "linux")] +use std::process::Command as StdCommand; + +use tempfile::tempdir; + +#[cfg(unix)] +struct BlockingStdinPipe { + original: i32, + write_end: i32, +} + +#[cfg(unix)] +impl BlockingStdinPipe { + fn install() -> Result { + let mut fds = [0i32; 2]; + if unsafe { libc::pipe(fds.as_mut_ptr()) } == -1 { + return Err(std::io::Error::last_os_error()).context("create stdin pipe"); + } + + let original = unsafe { libc::dup(libc::STDIN_FILENO) }; + if original == -1 { + let err = std::io::Error::last_os_error(); + unsafe { + libc::close(fds[0]); + libc::close(fds[1]); + } + return Err(err).context("dup stdin"); + } + + if unsafe { libc::dup2(fds[0], libc::STDIN_FILENO) } == -1 { + let err = std::io::Error::last_os_error(); + unsafe { + libc::close(fds[0]); + libc::close(fds[1]); + libc::close(original); + } + return Err(err).context("replace stdin"); + } + + unsafe { + libc::close(fds[0]); + } + + Ok(Self { + original, + write_end: fds[1], + }) + } +} + +#[cfg(unix)] +impl Drop for BlockingStdinPipe { + fn drop(&mut self) { + unsafe { + libc::dup2(self.original, libc::STDIN_FILENO); + libc::close(self.original); + libc::close(self.write_end); + } + } +} + +#[cfg(not(target_os = "windows"))] +fn assert_posix_snapshot_sections(snapshot: &str) { + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!( + snapshot.contains("PATH"), + "snapshot should capture a PATH export" + ); + assert!(snapshot.contains("setopts ")); +} + +async fn get_snapshot(shell_type: ShellType) -> Result { + let dir = tempdir()?; + let path = dir.path().join("snapshot.sh"); + write_shell_snapshot(shell_type, &path, dir.path()).await?; + let content = fs::read_to_string(&path).await?; + Ok(content) +} + +#[test] +fn strip_snapshot_preamble_removes_leading_output() { + let snapshot = "noise\n# Snapshot file\nexport PATH=/bin\n"; + let cleaned = strip_snapshot_preamble(snapshot).expect("snapshot marker exists"); + assert_eq!(cleaned, "# Snapshot file\nexport PATH=/bin\n"); +} + +#[test] +fn strip_snapshot_preamble_requires_marker() { + let result = strip_snapshot_preamble("missing header"); + assert!(result.is_err()); +} + +#[cfg(unix)] +#[test] +fn bash_snapshot_filters_invalid_exports() -> Result<()> { + let output = Command::new("/bin/bash") + .arg("-c") + .arg(bash_snapshot_script()) + .env("BASH_ENV", "/dev/null") + .env("VALID_NAME", "ok") + .env("PWD", "/tmp/stale") + .env("NEXTEST_BIN_EXE_codex-write-config-schema", "/path/to/bin") + .env("BAD-NAME", "broken") + .output()?; + + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("VALID_NAME")); + assert!(!stdout.contains("PWD=/tmp/stale")); + assert!(!stdout.contains("NEXTEST_BIN_EXE_codex-write-config-schema")); + assert!(!stdout.contains("BAD-NAME")); + + Ok(()) +} + +#[cfg(unix)] +#[test] +fn bash_snapshot_preserves_multiline_exports() -> Result<()> { + let multiline_cert = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----"; + let output = Command::new("/bin/bash") + .arg("-c") + .arg(bash_snapshot_script()) + .env("BASH_ENV", "/dev/null") + .env("MULTILINE_CERT", multiline_cert) + .output()?; + + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("MULTILINE_CERT=") || stdout.contains("MULTILINE_CERT"), + "snapshot should include the multiline export name" + ); + + let dir = tempdir()?; + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write(&snapshot_path, stdout.as_bytes())?; + + let validate = Command::new("/bin/bash") + .arg("-c") + .arg("set -e; . \"$1\"") + .arg("bash") + .arg(&snapshot_path) + .env("BASH_ENV", "/dev/null") + .output()?; + + assert!( + validate.status.success(), + "snapshot validation failed: {}", + String::from_utf8_lossy(&validate.stderr) + ); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> { + let dir = tempdir()?; + let shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }; + + let snapshot = ShellSnapshot::try_new(dir.path(), ThreadId::new(), dir.path(), &shell) + .await + .expect("snapshot should be created"); + let path = snapshot.path.clone(); + assert!(path.exists()); + assert_eq!(snapshot.cwd, dir.path().to_path_buf()); + + drop(snapshot); + + assert!(!path.exists()); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn snapshot_shell_does_not_inherit_stdin() -> Result<()> { + let _stdin_guard = BlockingStdinPipe::install()?; + + let dir = tempdir()?; + let home = dir.path(); + let read_status_path = home.join("stdin-read-status"); + let read_status_display = read_status_path.display(); + // Persist the startup `read` exit status so the test can assert whether + // bash saw EOF on stdin after the snapshot process exits. + let bashrc = format!("read -t 1 -r ignored\nprintf '%s' \"$?\" > \"{read_status_display}\"\n"); + fs::write(home.join(".bashrc"), bashrc).await?; + + let shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }; + + let home_display = home.display(); + let script = format!( + "HOME=\"{home_display}\"; export HOME; {}", + bash_snapshot_script() + ); + let output = run_script_with_timeout(&shell, &script, Duration::from_secs(2), true, home) + .await + .context("run snapshot command")?; + let read_status = fs::read_to_string(&read_status_path) + .await + .context("read stdin probe status")?; + + assert_eq!( + read_status, "1", + "expected shell startup read to see EOF on stdin; status={read_status:?}" + ); + + assert!( + output.contains("# Snapshot file"), + "expected snapshot marker in output; output={output:?}" + ); + + Ok(()) +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn timed_out_snapshot_shell_is_terminated() -> Result<()> { + use std::process::Stdio; + use tokio::time::Duration as TokioDuration; + use tokio::time::Instant; + use tokio::time::sleep; + + let dir = tempdir()?; + let pid_path = dir.path().join("pid"); + let script = format!("echo $$ > \"{}\"; sleep 30", pid_path.display()); + + let shell = Shell { + shell_type: ShellType::Sh, + shell_path: PathBuf::from("/bin/sh"), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }; + + let err = run_script_with_timeout(&shell, &script, Duration::from_secs(1), true, dir.path()) + .await + .expect_err("snapshot shell should time out"); + assert!( + err.to_string().contains("timed out"), + "expected timeout error, got {err:?}" + ); + + let pid = fs::read_to_string(&pid_path) + .await + .expect("snapshot shell writes its pid before timing out") + .trim() + .parse::()?; + + let deadline = Instant::now() + TokioDuration::from_secs(1); + loop { + let kill_status = StdCommand::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .status()?; + if !kill_status.success() { + break; + } + if Instant::now() >= deadline { + panic!("timed out snapshot shell is still alive after grace period"); + } + sleep(TokioDuration::from_millis(50)).await; + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn macos_zsh_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Zsh).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn linux_bash_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Bash).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn linux_sh_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Sh).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) +} + +#[cfg(target_os = "windows")] +#[ignore] +#[tokio::test] +async fn windows_powershell_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::PowerShell).await?; + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + Ok(()) +} + +async fn write_rollout_stub(codex_home: &Path, session_id: ThreadId) -> Result { + let dir = codex_home + .join("sessions") + .join("2025") + .join("01") + .join("01"); + fs::create_dir_all(&dir).await?; + let path = dir.join(format!("rollout-2025-01-01T00-00-00-{session_id}.jsonl")); + fs::write(&path, "").await?; + Ok(path) +} + +#[tokio::test] +async fn cleanup_stale_snapshots_removes_orphans_and_keeps_live() -> Result<()> { + let dir = tempdir()?; + let codex_home = dir.path(); + let snapshot_dir = codex_home.join(SNAPSHOT_DIR); + fs::create_dir_all(&snapshot_dir).await?; + + let live_session = ThreadId::new(); + let orphan_session = ThreadId::new(); + let live_snapshot = snapshot_dir.join(format!("{live_session}.sh")); + let orphan_snapshot = snapshot_dir.join(format!("{orphan_session}.sh")); + let invalid_snapshot = snapshot_dir.join("not-a-snapshot.txt"); + + write_rollout_stub(codex_home, live_session).await?; + fs::write(&live_snapshot, "live").await?; + fs::write(&orphan_snapshot, "orphan").await?; + fs::write(&invalid_snapshot, "invalid").await?; + + cleanup_stale_snapshots(codex_home, ThreadId::new()).await?; + + assert_eq!(live_snapshot.exists(), true); + assert_eq!(orphan_snapshot.exists(), false); + assert_eq!(invalid_snapshot.exists(), false); + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn cleanup_stale_snapshots_removes_stale_rollouts() -> Result<()> { + let dir = tempdir()?; + let codex_home = dir.path(); + let snapshot_dir = codex_home.join(SNAPSHOT_DIR); + fs::create_dir_all(&snapshot_dir).await?; + + let stale_session = ThreadId::new(); + let stale_snapshot = snapshot_dir.join(format!("{stale_session}.sh")); + let rollout_path = write_rollout_stub(codex_home, stale_session).await?; + fs::write(&stale_snapshot, "stale").await?; + + set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?; + + cleanup_stale_snapshots(codex_home, ThreadId::new()).await?; + + assert_eq!(stale_snapshot.exists(), false); + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn cleanup_stale_snapshots_skips_active_session() -> Result<()> { + let dir = tempdir()?; + let codex_home = dir.path(); + let snapshot_dir = codex_home.join(SNAPSHOT_DIR); + fs::create_dir_all(&snapshot_dir).await?; + + let active_session = ThreadId::new(); + let active_snapshot = snapshot_dir.join(format!("{active_session}.sh")); + let rollout_path = write_rollout_stub(codex_home, active_session).await?; + fs::write(&active_snapshot, "active").await?; + + set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?; + + cleanup_stale_snapshots(codex_home, active_session).await?; + + assert_eq!(active_snapshot.exists(), true); + Ok(()) +} + +#[cfg(unix)] +fn set_file_mtime(path: &Path, age: Duration) -> Result<()> { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs() + .saturating_sub(age.as_secs()); + let tv_sec = now + .try_into() + .map_err(|_| anyhow!("Snapshot mtime is out of range for libc::timespec"))?; + let ts = libc::timespec { tv_sec, tv_nsec: 0 }; + let times = [ts, ts]; + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())?; + let result = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) }; + if result != 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(()) +} diff --git a/codex-rs/core/src/shell_tests.rs b/codex-rs/core/src/shell_tests.rs new file mode 100644 index 0000000000..88025e8b85 --- /dev/null +++ b/codex-rs/core/src/shell_tests.rs @@ -0,0 +1,168 @@ +use super::*; +use std::path::PathBuf; +use std::process::Command; + +#[test] +#[cfg(target_os = "macos")] +fn detects_zsh() { + let zsh_shell = get_shell(ShellType::Zsh, None).unwrap(); + + let shell_path = zsh_shell.shell_path; + + assert_eq!(shell_path, std::path::Path::new("/bin/zsh")); +} + +#[test] +#[cfg(target_os = "macos")] +fn fish_fallback_to_zsh() { + let zsh_shell = default_user_shell_from_path(Some(PathBuf::from("/bin/fish"))); + + let shell_path = zsh_shell.shell_path; + + assert_eq!(shell_path, std::path::Path::new("/bin/zsh")); +} + +#[test] +fn detects_bash() { + let bash_shell = get_shell(ShellType::Bash, None).unwrap(); + let shell_path = bash_shell.shell_path; + + assert!( + shell_path.file_name().and_then(|name| name.to_str()) == Some("bash"), + "shell path: {shell_path:?}", + ); +} + +#[test] +fn detects_sh() { + let sh_shell = get_shell(ShellType::Sh, None).unwrap(); + let shell_path = sh_shell.shell_path; + assert!( + shell_path.file_name().and_then(|name| name.to_str()) == Some("sh"), + "shell path: {shell_path:?}", + ); +} + +#[test] +fn can_run_on_shell_test() { + let cmd = "echo \"Works\""; + if cfg!(windows) { + assert!(shell_works( + get_shell(ShellType::PowerShell, None), + "Out-String 'Works'", + true, + )); + assert!(shell_works(get_shell(ShellType::Cmd, None), cmd, true,)); + assert!(shell_works(Some(ultimate_fallback_shell()), cmd, true)); + } else { + assert!(shell_works(Some(ultimate_fallback_shell()), cmd, true)); + assert!(shell_works(get_shell(ShellType::Zsh, None), cmd, false)); + assert!(shell_works(get_shell(ShellType::Bash, None), cmd, true)); + assert!(shell_works(get_shell(ShellType::Sh, None), cmd, true)); + } +} + +fn shell_works(shell: Option, command: &str, required: bool) -> bool { + if let Some(shell) = shell { + let args = shell.derive_exec_args(command, false); + let output = Command::new(args[0].clone()) + .args(&args[1..]) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Works")); + true + } else { + !required + } +} + +#[test] +fn derive_exec_args() { + let test_bash_shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: empty_shell_snapshot_receiver(), + }; + assert_eq!( + test_bash_shell.derive_exec_args("echo hello", false), + vec!["/bin/bash", "-c", "echo hello"] + ); + assert_eq!( + test_bash_shell.derive_exec_args("echo hello", true), + vec!["/bin/bash", "-lc", "echo hello"] + ); + + let test_zsh_shell = Shell { + shell_type: ShellType::Zsh, + shell_path: PathBuf::from("/bin/zsh"), + shell_snapshot: empty_shell_snapshot_receiver(), + }; + assert_eq!( + test_zsh_shell.derive_exec_args("echo hello", false), + vec!["/bin/zsh", "-c", "echo hello"] + ); + assert_eq!( + test_zsh_shell.derive_exec_args("echo hello", true), + vec!["/bin/zsh", "-lc", "echo hello"] + ); + + let test_powershell_shell = Shell { + shell_type: ShellType::PowerShell, + shell_path: PathBuf::from("pwsh.exe"), + shell_snapshot: empty_shell_snapshot_receiver(), + }; + assert_eq!( + test_powershell_shell.derive_exec_args("echo hello", false), + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"] + ); + assert_eq!( + test_powershell_shell.derive_exec_args("echo hello", true), + vec!["pwsh.exe", "-Command", "echo hello"] + ); +} + +#[tokio::test] +async fn test_current_shell_detects_zsh() { + let shell = Command::new("sh") + .arg("-c") + .arg("echo $SHELL") + .output() + .unwrap(); + + let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string(); + if shell_path.ends_with("/zsh") { + assert_eq!( + default_user_shell(), + Shell { + shell_type: ShellType::Zsh, + shell_path: PathBuf::from(shell_path), + shell_snapshot: empty_shell_snapshot_receiver(), + } + ); + } +} + +#[tokio::test] +async fn detects_powershell_as_default() { + if !cfg!(windows) { + return; + } + + let powershell_shell = default_user_shell(); + let shell_path = powershell_shell.shell_path; + + assert!(shell_path.ends_with("pwsh.exe") || shell_path.ends_with("powershell.exe")); +} + +#[test] +fn finds_powershell() { + if !cfg!(windows) { + return; + } + + let powershell_shell = get_shell(ShellType::PowerShell, None).unwrap(); + let shell_path = powershell_shell.shell_path; + + assert!(shell_path.ends_with("pwsh.exe") || shell_path.ends_with("powershell.exe")); +} diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index d40e1bed02..549ccca87e 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -489,352 +489,5 @@ fn is_mention_name_char(byte: u8) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::collections::HashMap; - use std::collections::HashSet; - - fn make_skill(name: &str, path: &str) -> SkillMetadata { - SkillMetadata { - name: name.to_string(), - description: format!("{name} skill"), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: PathBuf::from(path), - scope: codex_protocol::protocol::SkillScope::User, - } - } - - fn set<'a>(items: &'a [&'a str]) -> HashSet<&'a str> { - items.iter().copied().collect() - } - - fn assert_mentions(text: &str, expected_names: &[&str], expected_paths: &[&str]) { - let mentions = extract_tool_mentions(text); - assert_eq!(mentions.names, set(expected_names)); - assert_eq!(mentions.paths, set(expected_paths)); - } - - fn collect_mentions( - inputs: &[UserInput], - skills: &[SkillMetadata], - disabled_paths: &HashSet, - connector_slug_counts: &HashMap, - ) -> Vec { - collect_explicit_skill_mentions(inputs, skills, disabled_paths, connector_slug_counts) - } - - #[test] - fn text_mentions_skill_requires_exact_boundary() { - assert_eq!( - true, - text_mentions_skill("use $notion-research-doc please", "notion-research-doc") - ); - assert_eq!( - true, - text_mentions_skill("($notion-research-doc)", "notion-research-doc") - ); - assert_eq!( - true, - text_mentions_skill("$notion-research-doc.", "notion-research-doc") - ); - assert_eq!( - false, - text_mentions_skill("$notion-research-docs", "notion-research-doc") - ); - assert_eq!( - false, - text_mentions_skill("$notion-research-doc_extra", "notion-research-doc") - ); - } - - #[test] - fn text_mentions_skill_handles_end_boundary_and_near_misses() { - assert_eq!(true, text_mentions_skill("$alpha-skill", "alpha-skill")); - assert_eq!(false, text_mentions_skill("$alpha-skillx", "alpha-skill")); - assert_eq!( - true, - text_mentions_skill("$alpha-skillx and later $alpha-skill ", "alpha-skill") - ); - } - - #[test] - fn text_mentions_skill_handles_many_dollars_without_looping() { - let prefix = "$".repeat(256); - let text = format!("{prefix} not-a-mention"); - assert_eq!(false, text_mentions_skill(&text, "alpha-skill")); - } - - #[test] - fn extract_tool_mentions_handles_plain_and_linked_mentions() { - assert_mentions( - "use $alpha and [$beta](/tmp/beta)", - &["alpha", "beta"], - &["/tmp/beta"], - ); - } - - #[test] - fn extract_tool_mentions_skips_common_env_vars() { - assert_mentions("use $PATH and $alpha", &["alpha"], &[]); - assert_mentions("use [$HOME](/tmp/skill)", &[], &[]); - assert_mentions("use $XDG_CONFIG_HOME and $beta", &["beta"], &[]); - } - - #[test] - fn extract_tool_mentions_requires_link_syntax() { - assert_mentions("[beta](/tmp/beta)", &[], &[]); - assert_mentions("[$beta] /tmp/beta", &["beta"], &[]); - assert_mentions("[$beta]()", &["beta"], &[]); - } - - #[test] - fn extract_tool_mentions_trims_linked_paths_and_allows_spacing() { - assert_mentions("use [$beta] ( /tmp/beta )", &["beta"], &["/tmp/beta"]); - } - - #[test] - fn extract_tool_mentions_stops_at_non_name_chars() { - assert_mentions( - "use $alpha.skill and $beta_extra", - &["alpha", "beta_extra"], - &[], - ); - } - - #[test] - fn extract_tool_mentions_keeps_plugin_skill_namespaces() { - assert_mentions( - "use $slack:search and $alpha", - &["alpha", "slack:search"], - &[], - ); - } - - #[test] - fn collect_explicit_skill_mentions_text_respects_skill_order() { - let alpha = make_skill("alpha-skill", "/tmp/alpha"); - let beta = make_skill("beta-skill", "/tmp/beta"); - let skills = vec![beta.clone(), alpha.clone()]; - let inputs = vec![UserInput::Text { - text: "first $alpha-skill then $beta-skill".to_string(), - text_elements: Vec::new(), - }]; - let connector_counts = HashMap::new(); - - let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - - // Text scanning should not change the previous selection ordering semantics. - assert_eq!(selected, vec![beta, alpha]); - } - - #[test] - fn collect_explicit_skill_mentions_prioritizes_structured_inputs() { - let alpha = make_skill("alpha-skill", "/tmp/alpha"); - let beta = make_skill("beta-skill", "/tmp/beta"); - let skills = vec![alpha.clone(), beta.clone()]; - let inputs = vec![ - UserInput::Text { - text: "please run $alpha-skill".to_string(), - text_elements: Vec::new(), - }, - UserInput::Skill { - name: "beta-skill".to_string(), - path: PathBuf::from("/tmp/beta"), - }, - ]; - let connector_counts = HashMap::new(); - - let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - - assert_eq!(selected, vec![beta, alpha]); - } - - #[test] - fn collect_explicit_skill_mentions_skips_invalid_structured_and_blocks_plain_fallback() { - let alpha = make_skill("alpha-skill", "/tmp/alpha"); - let skills = vec![alpha]; - let inputs = vec![ - UserInput::Text { - text: "please run $alpha-skill".to_string(), - text_elements: Vec::new(), - }, - UserInput::Skill { - name: "alpha-skill".to_string(), - path: PathBuf::from("/tmp/missing"), - }, - ]; - let connector_counts = HashMap::new(); - - let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - - assert_eq!(selected, Vec::new()); - } - - #[test] - fn collect_explicit_skill_mentions_skips_disabled_structured_and_blocks_plain_fallback() { - let alpha = make_skill("alpha-skill", "/tmp/alpha"); - let skills = vec![alpha]; - let inputs = vec![ - UserInput::Text { - text: "please run $alpha-skill".to_string(), - text_elements: Vec::new(), - }, - UserInput::Skill { - name: "alpha-skill".to_string(), - path: PathBuf::from("/tmp/alpha"), - }, - ]; - let disabled = HashSet::from([PathBuf::from("/tmp/alpha")]); - let connector_counts = HashMap::new(); - - let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts); - - assert_eq!(selected, Vec::new()); - } - - #[test] - fn collect_explicit_skill_mentions_dedupes_by_path() { - let alpha = make_skill("alpha-skill", "/tmp/alpha"); - let skills = vec![alpha.clone()]; - let inputs = vec![UserInput::Text { - text: "use [$alpha-skill](/tmp/alpha) and [$alpha-skill](/tmp/alpha)".to_string(), - text_elements: Vec::new(), - }]; - let connector_counts = HashMap::new(); - - let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - - assert_eq!(selected, vec![alpha]); - } - - #[test] - fn collect_explicit_skill_mentions_skips_ambiguous_name() { - let alpha = make_skill("demo-skill", "/tmp/alpha"); - let beta = make_skill("demo-skill", "/tmp/beta"); - let skills = vec![alpha, beta]; - let inputs = vec![UserInput::Text { - text: "use $demo-skill and again $demo-skill".to_string(), - text_elements: Vec::new(), - }]; - let connector_counts = HashMap::new(); - - let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - - assert_eq!(selected, Vec::new()); - } - - #[test] - fn collect_explicit_skill_mentions_prefers_linked_path_over_name() { - let alpha = make_skill("demo-skill", "/tmp/alpha"); - let beta = make_skill("demo-skill", "/tmp/beta"); - let skills = vec![alpha, beta.clone()]; - let inputs = vec![UserInput::Text { - text: "use $demo-skill and [$demo-skill](/tmp/beta)".to_string(), - text_elements: Vec::new(), - }]; - let connector_counts = HashMap::new(); - - let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - - assert_eq!(selected, vec![beta]); - } - - #[test] - fn collect_explicit_skill_mentions_skips_plain_name_when_connector_matches() { - let alpha = make_skill("alpha-skill", "/tmp/alpha"); - let skills = vec![alpha]; - let inputs = vec![UserInput::Text { - text: "use $alpha-skill".to_string(), - text_elements: Vec::new(), - }]; - let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]); - - let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - - assert_eq!(selected, Vec::new()); - } - - #[test] - fn collect_explicit_skill_mentions_allows_explicit_path_with_connector_conflict() { - let alpha = make_skill("alpha-skill", "/tmp/alpha"); - let skills = vec![alpha.clone()]; - let inputs = vec![UserInput::Text { - text: "use [$alpha-skill](/tmp/alpha)".to_string(), - text_elements: Vec::new(), - }]; - let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]); - - let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - - assert_eq!(selected, vec![alpha]); - } - - #[test] - fn collect_explicit_skill_mentions_skips_when_linked_path_disabled() { - let alpha = make_skill("demo-skill", "/tmp/alpha"); - let beta = make_skill("demo-skill", "/tmp/beta"); - let skills = vec![alpha, beta]; - let inputs = vec![UserInput::Text { - text: "use [$demo-skill](/tmp/alpha)".to_string(), - text_elements: Vec::new(), - }]; - let disabled = HashSet::from([PathBuf::from("/tmp/alpha")]); - let connector_counts = HashMap::new(); - - let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts); - - assert_eq!(selected, Vec::new()); - } - - #[test] - fn collect_explicit_skill_mentions_prefers_resource_path() { - let alpha = make_skill("demo-skill", "/tmp/alpha"); - let beta = make_skill("demo-skill", "/tmp/beta"); - let skills = vec![alpha, beta.clone()]; - let inputs = vec![UserInput::Text { - text: "use [$demo-skill](/tmp/beta)".to_string(), - text_elements: Vec::new(), - }]; - let connector_counts = HashMap::new(); - - let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - - assert_eq!(selected, vec![beta]); - } - - #[test] - fn collect_explicit_skill_mentions_skips_missing_path_with_no_fallback() { - let alpha = make_skill("demo-skill", "/tmp/alpha"); - let beta = make_skill("demo-skill", "/tmp/beta"); - let skills = vec![alpha, beta]; - let inputs = vec![UserInput::Text { - text: "use [$demo-skill](/tmp/missing)".to_string(), - text_elements: Vec::new(), - }]; - let connector_counts = HashMap::new(); - - let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - - assert_eq!(selected, Vec::new()); - } - - #[test] - fn collect_explicit_skill_mentions_skips_missing_path_without_fallback() { - let alpha = make_skill("demo-skill", "/tmp/alpha"); - let skills = vec![alpha]; - let inputs = vec![UserInput::Text { - text: "use [$demo-skill](/tmp/missing)".to_string(), - text_elements: Vec::new(), - }]; - let connector_counts = HashMap::new(); - - let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - - assert_eq!(selected, Vec::new()); - } -} +#[path = "injection_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/skills/injection_tests.rs b/codex-rs/core/src/skills/injection_tests.rs new file mode 100644 index 0000000000..74ff315bb8 --- /dev/null +++ b/codex-rs/core/src/skills/injection_tests.rs @@ -0,0 +1,347 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::collections::HashSet; + +fn make_skill(name: &str, path: &str) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: format!("{name} skill"), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: PathBuf::from(path), + scope: codex_protocol::protocol::SkillScope::User, + } +} + +fn set<'a>(items: &'a [&'a str]) -> HashSet<&'a str> { + items.iter().copied().collect() +} + +fn assert_mentions(text: &str, expected_names: &[&str], expected_paths: &[&str]) { + let mentions = extract_tool_mentions(text); + assert_eq!(mentions.names, set(expected_names)); + assert_eq!(mentions.paths, set(expected_paths)); +} + +fn collect_mentions( + inputs: &[UserInput], + skills: &[SkillMetadata], + disabled_paths: &HashSet, + connector_slug_counts: &HashMap, +) -> Vec { + collect_explicit_skill_mentions(inputs, skills, disabled_paths, connector_slug_counts) +} + +#[test] +fn text_mentions_skill_requires_exact_boundary() { + assert_eq!( + true, + text_mentions_skill("use $notion-research-doc please", "notion-research-doc") + ); + assert_eq!( + true, + text_mentions_skill("($notion-research-doc)", "notion-research-doc") + ); + assert_eq!( + true, + text_mentions_skill("$notion-research-doc.", "notion-research-doc") + ); + assert_eq!( + false, + text_mentions_skill("$notion-research-docs", "notion-research-doc") + ); + assert_eq!( + false, + text_mentions_skill("$notion-research-doc_extra", "notion-research-doc") + ); +} + +#[test] +fn text_mentions_skill_handles_end_boundary_and_near_misses() { + assert_eq!(true, text_mentions_skill("$alpha-skill", "alpha-skill")); + assert_eq!(false, text_mentions_skill("$alpha-skillx", "alpha-skill")); + assert_eq!( + true, + text_mentions_skill("$alpha-skillx and later $alpha-skill ", "alpha-skill") + ); +} + +#[test] +fn text_mentions_skill_handles_many_dollars_without_looping() { + let prefix = "$".repeat(256); + let text = format!("{prefix} not-a-mention"); + assert_eq!(false, text_mentions_skill(&text, "alpha-skill")); +} + +#[test] +fn extract_tool_mentions_handles_plain_and_linked_mentions() { + assert_mentions( + "use $alpha and [$beta](/tmp/beta)", + &["alpha", "beta"], + &["/tmp/beta"], + ); +} + +#[test] +fn extract_tool_mentions_skips_common_env_vars() { + assert_mentions("use $PATH and $alpha", &["alpha"], &[]); + assert_mentions("use [$HOME](/tmp/skill)", &[], &[]); + assert_mentions("use $XDG_CONFIG_HOME and $beta", &["beta"], &[]); +} + +#[test] +fn extract_tool_mentions_requires_link_syntax() { + assert_mentions("[beta](/tmp/beta)", &[], &[]); + assert_mentions("[$beta] /tmp/beta", &["beta"], &[]); + assert_mentions("[$beta]()", &["beta"], &[]); +} + +#[test] +fn extract_tool_mentions_trims_linked_paths_and_allows_spacing() { + assert_mentions("use [$beta] ( /tmp/beta )", &["beta"], &["/tmp/beta"]); +} + +#[test] +fn extract_tool_mentions_stops_at_non_name_chars() { + assert_mentions( + "use $alpha.skill and $beta_extra", + &["alpha", "beta_extra"], + &[], + ); +} + +#[test] +fn extract_tool_mentions_keeps_plugin_skill_namespaces() { + assert_mentions( + "use $slack:search and $alpha", + &["alpha", "slack:search"], + &[], + ); +} + +#[test] +fn collect_explicit_skill_mentions_text_respects_skill_order() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let beta = make_skill("beta-skill", "/tmp/beta"); + let skills = vec![beta.clone(), alpha.clone()]; + let inputs = vec![UserInput::Text { + text: "first $alpha-skill then $beta-skill".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + // Text scanning should not change the previous selection ordering semantics. + assert_eq!(selected, vec![beta, alpha]); +} + +#[test] +fn collect_explicit_skill_mentions_prioritizes_structured_inputs() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let beta = make_skill("beta-skill", "/tmp/beta"); + let skills = vec![alpha.clone(), beta.clone()]; + let inputs = vec![ + UserInput::Text { + text: "please run $alpha-skill".to_string(), + text_elements: Vec::new(), + }, + UserInput::Skill { + name: "beta-skill".to_string(), + path: PathBuf::from("/tmp/beta"), + }, + ]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![beta, alpha]); +} + +#[test] +fn collect_explicit_skill_mentions_skips_invalid_structured_and_blocks_plain_fallback() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![ + UserInput::Text { + text: "please run $alpha-skill".to_string(), + text_elements: Vec::new(), + }, + UserInput::Skill { + name: "alpha-skill".to_string(), + path: PathBuf::from("/tmp/missing"), + }, + ]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_skips_disabled_structured_and_blocks_plain_fallback() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![ + UserInput::Text { + text: "please run $alpha-skill".to_string(), + text_elements: Vec::new(), + }, + UserInput::Skill { + name: "alpha-skill".to_string(), + path: PathBuf::from("/tmp/alpha"), + }, + ]; + let disabled = HashSet::from([PathBuf::from("/tmp/alpha")]); + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_dedupes_by_path() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha.clone()]; + let inputs = vec![UserInput::Text { + text: "use [$alpha-skill](/tmp/alpha) and [$alpha-skill](/tmp/alpha)".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![alpha]); +} + +#[test] +fn collect_explicit_skill_mentions_skips_ambiguous_name() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let beta = make_skill("demo-skill", "/tmp/beta"); + let skills = vec![alpha, beta]; + let inputs = vec![UserInput::Text { + text: "use $demo-skill and again $demo-skill".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_prefers_linked_path_over_name() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let beta = make_skill("demo-skill", "/tmp/beta"); + let skills = vec![alpha, beta.clone()]; + let inputs = vec![UserInput::Text { + text: "use $demo-skill and [$demo-skill](/tmp/beta)".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![beta]); +} + +#[test] +fn collect_explicit_skill_mentions_skips_plain_name_when_connector_matches() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![UserInput::Text { + text: "use $alpha-skill".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_allows_explicit_path_with_connector_conflict() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha.clone()]; + let inputs = vec![UserInput::Text { + text: "use [$alpha-skill](/tmp/alpha)".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![alpha]); +} + +#[test] +fn collect_explicit_skill_mentions_skips_when_linked_path_disabled() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let beta = make_skill("demo-skill", "/tmp/beta"); + let skills = vec![alpha, beta]; + let inputs = vec![UserInput::Text { + text: "use [$demo-skill](/tmp/alpha)".to_string(), + text_elements: Vec::new(), + }]; + let disabled = HashSet::from([PathBuf::from("/tmp/alpha")]); + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_prefers_resource_path() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let beta = make_skill("demo-skill", "/tmp/beta"); + let skills = vec![alpha, beta.clone()]; + let inputs = vec![UserInput::Text { + text: "use [$demo-skill](/tmp/beta)".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![beta]); +} + +#[test] +fn collect_explicit_skill_mentions_skips_missing_path_with_no_fallback() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let beta = make_skill("demo-skill", "/tmp/beta"); + let skills = vec![alpha, beta]; + let inputs = vec![UserInput::Text { + text: "use [$demo-skill](/tmp/missing)".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_skips_missing_path_without_fallback() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![UserInput::Text { + text: "use [$demo-skill](/tmp/missing)".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); +} diff --git a/codex-rs/core/src/skills/invocation_utils.rs b/codex-rs/core/src/skills/invocation_utils.rs index c4310baceb..d9040d3b91 100644 --- a/codex-rs/core/src/skills/invocation_utils.rs +++ b/codex-rs/core/src/skills/invocation_utils.rs @@ -231,126 +231,5 @@ fn normalize_path(path: &Path) -> PathBuf { } #[cfg(test)] -mod tests { - use super::SkillLoadOutcome; - use super::SkillMetadata; - use super::detect_skill_doc_read; - use super::detect_skill_script_run; - use super::normalize_path; - use super::script_run_token; - use pretty_assertions::assert_eq; - use std::collections::HashMap; - use std::path::Path; - use std::path::PathBuf; - use std::sync::Arc; - - fn test_skill_metadata(skill_doc_path: PathBuf) -> SkillMetadata { - SkillMetadata { - name: "test-skill".to_string(), - description: "test".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: skill_doc_path, - scope: codex_protocol::protocol::SkillScope::User, - } - } - - #[test] - fn script_run_detection_matches_runner_plus_extension() { - let tokens = vec![ - "python3".to_string(), - "-u".to_string(), - "scripts/fetch_comments.py".to_string(), - ]; - - assert_eq!(script_run_token(&tokens).is_some(), true); - } - - #[test] - fn script_run_detection_excludes_python_c() { - let tokens = vec![ - "python3".to_string(), - "-c".to_string(), - "print(1)".to_string(), - ]; - - assert_eq!(script_run_token(&tokens).is_some(), false); - } - - #[test] - fn skill_doc_read_detection_matches_absolute_path() { - let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md"); - let normalized_skill_doc_path = normalize_path(skill_doc_path.as_path()); - let skill = test_skill_metadata(skill_doc_path); - let outcome = SkillLoadOutcome { - implicit_skills_by_scripts_dir: Arc::new(HashMap::new()), - implicit_skills_by_doc_path: Arc::new(HashMap::from([( - normalized_skill_doc_path, - skill, - )])), - ..Default::default() - }; - - let tokens = vec![ - "cat".to_string(), - "/tmp/skill-test/SKILL.md".to_string(), - "|".to_string(), - "head".to_string(), - ]; - let found = detect_skill_doc_read(&outcome, &tokens, Path::new("/tmp")); - - assert_eq!( - found.map(|value| value.name), - Some("test-skill".to_string()) - ); - } - - #[test] - fn skill_script_run_detection_matches_relative_path_from_skill_root() { - let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md"); - let scripts_dir = normalize_path(Path::new("/tmp/skill-test/scripts")); - let skill = test_skill_metadata(skill_doc_path); - let outcome = SkillLoadOutcome { - implicit_skills_by_scripts_dir: Arc::new(HashMap::from([(scripts_dir, skill)])), - implicit_skills_by_doc_path: Arc::new(HashMap::new()), - ..Default::default() - }; - let tokens = vec![ - "python3".to_string(), - "scripts/fetch_comments.py".to_string(), - ]; - - let found = detect_skill_script_run(&outcome, &tokens, Path::new("/tmp/skill-test")); - - assert_eq!( - found.map(|value| value.name), - Some("test-skill".to_string()) - ); - } - - #[test] - fn skill_script_run_detection_matches_absolute_path_from_any_workdir() { - let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md"); - let scripts_dir = normalize_path(Path::new("/tmp/skill-test/scripts")); - let skill = test_skill_metadata(skill_doc_path); - let outcome = SkillLoadOutcome { - implicit_skills_by_scripts_dir: Arc::new(HashMap::from([(scripts_dir, skill)])), - implicit_skills_by_doc_path: Arc::new(HashMap::new()), - ..Default::default() - }; - let tokens = vec![ - "python3".to_string(), - "/tmp/skill-test/scripts/fetch_comments.py".to_string(), - ]; - - let found = detect_skill_script_run(&outcome, &tokens, Path::new("/tmp/other")); - - assert_eq!( - found.map(|value| value.name), - Some("test-skill".to_string()) - ); - } -} +#[path = "invocation_utils_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/skills/invocation_utils_tests.rs b/codex-rs/core/src/skills/invocation_utils_tests.rs new file mode 100644 index 0000000000..bf244ce767 --- /dev/null +++ b/codex-rs/core/src/skills/invocation_utils_tests.rs @@ -0,0 +1,118 @@ +use super::SkillLoadOutcome; +use super::SkillMetadata; +use super::detect_skill_doc_read; +use super::detect_skill_script_run; +use super::normalize_path; +use super::script_run_token; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +fn test_skill_metadata(skill_doc_path: PathBuf) -> SkillMetadata { + SkillMetadata { + name: "test-skill".to_string(), + description: "test".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: skill_doc_path, + scope: codex_protocol::protocol::SkillScope::User, + } +} + +#[test] +fn script_run_detection_matches_runner_plus_extension() { + let tokens = vec![ + "python3".to_string(), + "-u".to_string(), + "scripts/fetch_comments.py".to_string(), + ]; + + assert_eq!(script_run_token(&tokens).is_some(), true); +} + +#[test] +fn script_run_detection_excludes_python_c() { + let tokens = vec![ + "python3".to_string(), + "-c".to_string(), + "print(1)".to_string(), + ]; + + assert_eq!(script_run_token(&tokens).is_some(), false); +} + +#[test] +fn skill_doc_read_detection_matches_absolute_path() { + let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md"); + let normalized_skill_doc_path = normalize_path(skill_doc_path.as_path()); + let skill = test_skill_metadata(skill_doc_path); + let outcome = SkillLoadOutcome { + implicit_skills_by_scripts_dir: Arc::new(HashMap::new()), + implicit_skills_by_doc_path: Arc::new(HashMap::from([(normalized_skill_doc_path, skill)])), + ..Default::default() + }; + + let tokens = vec![ + "cat".to_string(), + "/tmp/skill-test/SKILL.md".to_string(), + "|".to_string(), + "head".to_string(), + ]; + let found = detect_skill_doc_read(&outcome, &tokens, Path::new("/tmp")); + + assert_eq!( + found.map(|value| value.name), + Some("test-skill".to_string()) + ); +} + +#[test] +fn skill_script_run_detection_matches_relative_path_from_skill_root() { + let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md"); + let scripts_dir = normalize_path(Path::new("/tmp/skill-test/scripts")); + let skill = test_skill_metadata(skill_doc_path); + let outcome = SkillLoadOutcome { + implicit_skills_by_scripts_dir: Arc::new(HashMap::from([(scripts_dir, skill)])), + implicit_skills_by_doc_path: Arc::new(HashMap::new()), + ..Default::default() + }; + let tokens = vec![ + "python3".to_string(), + "scripts/fetch_comments.py".to_string(), + ]; + + let found = detect_skill_script_run(&outcome, &tokens, Path::new("/tmp/skill-test")); + + assert_eq!( + found.map(|value| value.name), + Some("test-skill".to_string()) + ); +} + +#[test] +fn skill_script_run_detection_matches_absolute_path_from_any_workdir() { + let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md"); + let scripts_dir = normalize_path(Path::new("/tmp/skill-test/scripts")); + let skill = test_skill_metadata(skill_doc_path); + let outcome = SkillLoadOutcome { + implicit_skills_by_scripts_dir: Arc::new(HashMap::from([(scripts_dir, skill)])), + implicit_skills_by_doc_path: Arc::new(HashMap::new()), + ..Default::default() + }; + let tokens = vec![ + "python3".to_string(), + "/tmp/skill-test/scripts/fetch_comments.py".to_string(), + ]; + + let found = detect_skill_script_run(&outcome, &tokens, Path::new("/tmp/other")); + + assert_eq!( + found.map(|value| value.name), + Some("test-skill".to_string()) + ); +} diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 84c73f9e20..b2dd48b3ae 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -853,1914 +853,5 @@ pub(crate) fn skill_roots_from_layer_stack( } #[cfg(test)] -mod tests { - use super::*; - use crate::config::ConfigBuilder; - use crate::config::ConfigOverrides; - use crate::config::ConfigToml; - use crate::config::ProjectConfig; - use crate::config_loader::ConfigLayerEntry; - use crate::config_loader::ConfigLayerStack; - use crate::config_loader::ConfigRequirements; - use crate::config_loader::ConfigRequirementsToml; - use codex_config::CONFIG_TOML_FILE; - use codex_protocol::config_types::TrustLevel; - use codex_protocol::models::FileSystemPermissions; - use codex_protocol::models::MacOsAutomationPermission; - use codex_protocol::models::MacOsContactsPermission; - use codex_protocol::models::MacOsPreferencesPermission; - use codex_protocol::models::MacOsSeatbeltProfileExtensions; - use codex_protocol::models::PermissionProfile; - use codex_protocol::protocol::SkillScope; - use codex_utils_absolute_path::AbsolutePathBuf; - use pretty_assertions::assert_eq; - use std::collections::HashMap; - use std::path::Path; - use tempfile::TempDir; - use toml::Value as TomlValue; - - const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex"; - - async fn make_config(codex_home: &TempDir) -> Config { - make_config_for_cwd(codex_home, codex_home.path().to_path_buf()).await - } - - async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config { - let trust_root = cwd - .ancestors() - .find(|ancestor| ancestor.join(".git").exists()) - .map(Path::to_path_buf) - .unwrap_or_else(|| cwd.clone()); - - fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - toml::to_string(&ConfigToml { - projects: Some(HashMap::from([( - trust_root.to_string_lossy().to_string(), - ProjectConfig { - trust_level: Some(TrustLevel::Trusted), - }, - )])), - ..Default::default() - }) - .expect("serialize config"), - ) - .unwrap(); - - let harness_overrides = ConfigOverrides { - cwd: Some(cwd), - ..Default::default() - }; - - ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(harness_overrides) - .build() - .await - .expect("defaults for test should always succeed") - } - - fn load_skills_for_test(config: &Config) -> SkillLoadOutcome { - // Keep unit tests hermetic by never scanning the real `$HOME/.agents/skills`. - super::load_skills_from_roots(super::skill_roots_with_home_dir( - &config.config_layer_stack, - &config.cwd, - None, - Vec::new(), - )) - } - - fn mark_as_git_repo(dir: &Path) { - // Config/project-root discovery only checks for the presence of `.git` (file or dir), - // so we can avoid shelling out to `git init` in tests. - fs::write(dir.join(".git"), "gitdir: fake\n").unwrap(); - } - - fn normalized(path: &Path) -> PathBuf { - canonicalize_path(path).unwrap_or_else(|_| path.to_path_buf()) - } - - #[test] - fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_system_to_admin() - -> anyhow::Result<()> { - let tmp = tempfile::tempdir()?; - - let system_folder = tmp.path().join("etc/codex"); - let home_folder = tmp.path().join("home"); - let user_folder = home_folder.join("codex"); - fs::create_dir_all(&system_folder)?; - fs::create_dir_all(&user_folder)?; - - // The file path doesn't need to exist; it's only used to derive the config folder. - let system_file = AbsolutePathBuf::from_absolute_path(system_folder.join("config.toml"))?; - let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?; - - let layers = vec![ - ConfigLayerEntry::new( - ConfigLayerSource::System { file: system_file }, - TomlValue::Table(toml::map::Map::new()), - ), - ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, - TomlValue::Table(toml::map::Map::new()), - ), - ]; - let stack = ConfigLayerStack::new( - layers, - ConfigRequirements::default(), - ConfigRequirementsToml::default(), - )?; - - let got = skill_roots_from_layer_stack(&stack, Some(&home_folder)) - .into_iter() - .map(|root| (root.scope, root.path)) - .collect::>(); - - assert_eq!( - got, - vec![ - (SkillScope::User, user_folder.join("skills")), - ( - SkillScope::User, - home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME) - ), - ( - SkillScope::System, - user_folder.join("skills").join(".system") - ), - (SkillScope::Admin, system_folder.join("skills")), - ] - ); - - Ok(()) - } - - #[test] - fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Result<()> { - let tmp = tempfile::tempdir()?; - - let home_folder = tmp.path().join("home"); - let user_folder = home_folder.join("codex"); - fs::create_dir_all(&user_folder)?; - - let project_root = tmp.path().join("repo"); - let dot_codex = project_root.join(".codex"); - fs::create_dir_all(&dot_codex)?; - - let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?; - let project_dot_codex = AbsolutePathBuf::from_absolute_path(&dot_codex)?; - - let layers = vec![ - ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, - TomlValue::Table(toml::map::Map::new()), - ), - ConfigLayerEntry::new_disabled( - ConfigLayerSource::Project { - dot_codex_folder: project_dot_codex, - }, - TomlValue::Table(toml::map::Map::new()), - "marked untrusted", - ), - ]; - let stack = ConfigLayerStack::new( - layers, - ConfigRequirements::default(), - ConfigRequirementsToml::default(), - )?; - - let got = skill_roots_from_layer_stack(&stack, Some(&home_folder)) - .into_iter() - .map(|root| (root.scope, root.path)) - .collect::>(); - - assert_eq!( - got, - vec![ - (SkillScope::Repo, dot_codex.join("skills")), - (SkillScope::User, user_folder.join("skills")), - ( - SkillScope::User, - home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME) - ), - ( - SkillScope::System, - user_folder.join("skills").join(".system") - ), - ] - ); - - Ok(()) - } - - #[test] - fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> { - let tmp = tempfile::tempdir()?; - - let home_folder = tmp.path().join("home"); - let user_folder = home_folder.join("codex"); - fs::create_dir_all(&user_folder)?; - - let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?; - let layers = vec![ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, - TomlValue::Table(toml::map::Map::new()), - )]; - let stack = ConfigLayerStack::new( - layers, - ConfigRequirements::default(), - ConfigRequirementsToml::default(), - )?; - - let skill_path = write_skill_at( - &home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME), - "agents-home", - "agents-home-skill", - "from home agents", - ); - - let outcome = - load_skills_from_roots(skill_roots_from_layer_stack(&stack, Some(&home_folder))); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "agents-home-skill".to_string(), - description: "from home agents".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::User, - }] - ); - - Ok(()) - } - - fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf { - write_skill_at(&codex_home.path().join("skills"), dir, name, description) - } - - fn write_system_skill( - codex_home: &TempDir, - dir: &str, - name: &str, - description: &str, - ) -> PathBuf { - write_skill_at( - &codex_home.path().join("skills/.system"), - dir, - name, - description, - ) - } - - fn write_skill_at(root: &Path, dir: &str, name: &str, description: &str) -> PathBuf { - let skill_dir = root.join(dir); - fs::create_dir_all(&skill_dir).unwrap(); - let indented_description = description.replace('\n', "\n "); - let content = format!( - "---\nname: {name}\ndescription: |-\n {indented_description}\n---\n\n# Body\n" - ); - let path = skill_dir.join(SKILLS_FILENAME); - fs::write(&path, content).unwrap(); - path - } - - fn write_raw_skill_at(root: &Path, dir: &str, frontmatter: &str) -> PathBuf { - let skill_dir = root.join(dir); - fs::create_dir_all(&skill_dir).unwrap(); - let path = skill_dir.join(SKILLS_FILENAME); - let content = format!("---\n{frontmatter}\n---\n\n# Body\n"); - fs::write(&path, content).unwrap(); - path - } - - fn write_skill_metadata_at(skill_dir: &Path, contents: &str) -> PathBuf { - let path = skill_dir - .join(SKILLS_METADATA_DIR) - .join(SKILLS_METADATA_FILENAME); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(&path, contents).unwrap(); - path - } - - fn write_skill_interface_at(skill_dir: &Path, contents: &str) -> PathBuf { - write_skill_metadata_at(skill_dir, contents) - } - - #[tokio::test] - async fn loads_skill_dependencies_metadata_from_yaml() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "dep-skill", "from json"); - let skill_dir = skill_path.parent().expect("skill dir"); - - write_skill_metadata_at( - skill_dir, - r#" -{ - "dependencies": { - "tools": [ - { - "type": "env_var", - "value": "GITHUB_TOKEN", - "description": "GitHub API token with repo scopes" - }, - { - "type": "mcp", - "value": "github", - "description": "GitHub MCP server", - "transport": "streamable_http", - "url": "https://example.com/mcp" - }, - { - "type": "cli", - "value": "gh", - "description": "GitHub CLI" - }, - { - "type": "mcp", - "value": "local-gh", - "description": "Local GH MCP server", - "transport": "stdio", - "command": "gh-mcp" - } - ] - } -} -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "dep-skill".to_string(), - description: "from json".to_string(), - short_description: None, - interface: None, - dependencies: Some(SkillDependencies { - tools: vec![ - SkillToolDependency { - r#type: "env_var".to_string(), - value: "GITHUB_TOKEN".to_string(), - description: Some("GitHub API token with repo scopes".to_string()), - transport: None, - command: None, - url: None, - }, - SkillToolDependency { - r#type: "mcp".to_string(), - value: "github".to_string(), - description: Some("GitHub MCP server".to_string()), - transport: Some("streamable_http".to_string()), - command: None, - url: Some("https://example.com/mcp".to_string()), - }, - SkillToolDependency { - r#type: "cli".to_string(), - value: "gh".to_string(), - description: Some("GitHub CLI".to_string()), - transport: None, - command: None, - url: None, - }, - SkillToolDependency { - r#type: "mcp".to_string(), - value: "local-gh".to_string(), - description: Some("Local GH MCP server".to_string()), - transport: Some("stdio".to_string()), - command: Some("gh-mcp".to_string()), - url: None, - }, - ], - }), - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn loads_skill_interface_metadata_from_yaml() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); - let skill_dir = skill_path.parent().expect("skill dir"); - let normalized_skill_dir = normalized(skill_dir); - - write_skill_interface_at( - skill_dir, - r##" -interface: - display_name: "UI Skill" - short_description: " short desc " - icon_small: "./assets/small-400px.png" - icon_large: "./assets/large-logo.svg" - brand_color: "#3B82F6" - default_prompt: " default prompt " -"##, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - let user_skills: Vec = outcome - .skills - .into_iter() - .filter(|skill| skill.scope == SkillScope::User) - .collect(); - assert_eq!( - user_skills, - vec![SkillMetadata { - name: "ui-skill".to_string(), - description: "from json".to_string(), - short_description: None, - interface: Some(SkillInterface { - display_name: Some("UI Skill".to_string()), - short_description: Some("short desc".to_string()), - icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")), - icon_large: Some(normalized_skill_dir.join("assets/large-logo.svg")), - brand_color: Some("#3B82F6".to_string()), - default_prompt: Some("default prompt".to_string()), - }), - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(skill_path.as_path()), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn loads_skill_policy_from_yaml() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "policy-skill", "from json"); - let skill_dir = skill_path.parent().expect("skill dir"); - - write_skill_metadata_at( - skill_dir, - r#" -policy: - allow_implicit_invocation: false -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 1); - assert_eq!( - outcome.skills[0].policy, - Some(SkillPolicy { - allow_implicit_invocation: Some(false), - }) - ); - assert!(outcome.allowed_skills_for_implicit_invocation().is_empty()); - } - - #[tokio::test] - async fn empty_skill_policy_defaults_to_allow_implicit_invocation() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "policy-empty", "from json"); - let skill_dir = skill_path.parent().expect("skill dir"); - - write_skill_metadata_at( - skill_dir, - r#" -policy: {} -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 1); - assert_eq!( - outcome.skills[0].policy, - Some(SkillPolicy { - allow_implicit_invocation: None, - }) - ); - assert_eq!( - outcome.allowed_skills_for_implicit_invocation(), - outcome.skills - ); - } - - #[tokio::test] - async fn loads_skill_permissions_from_yaml() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "permissions-skill", "from yaml"); - let skill_dir = skill_path.parent().expect("skill dir"); - fs::create_dir_all(skill_dir.join("data")).expect("create read path"); - fs::create_dir_all(skill_dir.join("output")).expect("create write path"); - - write_skill_metadata_at( - skill_dir, - r#" -permissions: - network: - enabled: true - file_system: - read: - - "./data" - write: - - "./output" -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 1); - assert_eq!( - outcome.skills[0].permission_profile, - Some(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions { - read: Some(vec![ - AbsolutePathBuf::try_from(normalized(skill_dir.join("data").as_path())) - .expect("absolute data path"), - ]), - write: Some(vec![ - AbsolutePathBuf::try_from(normalized(skill_dir.join("output").as_path())) - .expect("absolute output path"), - ]), - }), - macos: None, - }) - ); - } - - #[tokio::test] - async fn empty_skill_permissions_do_not_create_profile() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "permissions-empty", "from yaml"); - let skill_dir = skill_path.parent().expect("skill dir"); - - write_skill_metadata_at( - skill_dir, - r#" -permissions: {} -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 1); - assert_eq!(outcome.skills[0].permission_profile, None); - } - - #[test] - fn skill_metadata_parses_macos_permissions_yaml() { - let parsed = serde_yaml::from_str::( - r#" -permissions: - macos: - macos_preferences: "read_write" - macos_automation: - - "com.apple.Notes" - macos_launch_services: true - macos_accessibility: true - macos_calendar: true -"#, - ) - .expect("parse skill metadata"); - - assert_eq!( - parsed.permissions, - Some(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }) - ); - } - - #[test] - fn skill_metadata_parses_macos_reminders_permission_yaml() { - let parsed = serde_yaml::from_str::( - r#" -permissions: - macos: - macos_reminders: true -"#, - ) - .expect("parse reminders skill metadata"); - - assert_eq!( - parsed.permissions, - Some(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - macos_automation: MacOsAutomationPermission::None, - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }) - ); - } - - #[cfg(target_os = "macos")] - #[tokio::test] - async fn loads_skill_macos_permissions_from_yaml() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "permissions-macos", "from yaml"); - let skill_dir = skill_path.parent().expect("skill dir"); - - write_skill_metadata_at( - skill_dir, - r#" -permissions: - macos: - macos_preferences: "read_write" - macos_automation: - - "com.apple.Notes" - macos_launch_services: true - macos_accessibility: true - macos_calendar: true -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 1); - assert_eq!( - outcome.skills[0].permission_profile, - Some(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string() - ],), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }) - ); - } - - #[cfg(not(target_os = "macos"))] - #[tokio::test] - async fn loads_skill_macos_permissions_from_yaml_non_macos_does_not_create_profile() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "permissions-macos", "from yaml"); - let skill_dir = skill_path.parent().expect("skill dir"); - - write_skill_metadata_at( - skill_dir, - r#" -permissions: - macos: - macos_preferences: "read_write" - macos_automation: - - "com.apple.Notes" - macos_launch_services: true - macos_accessibility: true - macos_calendar: true -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 1); - assert_eq!( - outcome.skills[0].permission_profile, - Some(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string() - ],), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }) - ); - } - - #[tokio::test] - async fn accepts_icon_paths_under_assets_dir() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); - let skill_dir = skill_path.parent().expect("skill dir"); - let normalized_skill_dir = normalized(skill_dir); - - write_skill_interface_at( - skill_dir, - r#" -{ - "interface": { - "display_name": "UI Skill", - "icon_small": "assets/icon.png", - "icon_large": "./assets/logo.svg" - } -} -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "ui-skill".to_string(), - description: "from json".to_string(), - short_description: None, - interface: Some(SkillInterface { - display_name: Some("UI Skill".to_string()), - short_description: None, - icon_small: Some(normalized_skill_dir.join("assets/icon.png")), - icon_large: Some(normalized_skill_dir.join("assets/logo.svg")), - brand_color: None, - default_prompt: None, - }), - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn ignores_invalid_brand_color() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); - let skill_dir = skill_path.parent().expect("skill dir"); - - write_skill_interface_at( - skill_dir, - r#" -{ - "interface": { - "brand_color": "blue" - } -} -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "ui-skill".to_string(), - description: "from json".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn ignores_default_prompt_over_max_length() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); - let skill_dir = skill_path.parent().expect("skill dir"); - let normalized_skill_dir = normalized(skill_dir); - let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1); - - write_skill_interface_at( - skill_dir, - &format!( - r##" -{{ - "interface": {{ - "display_name": "UI Skill", - "icon_small": "./assets/small-400px.png", - "default_prompt": "{too_long}" - }} -}} -"## - ), - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "ui-skill".to_string(), - description: "from json".to_string(), - short_description: None, - interface: Some(SkillInterface { - display_name: Some("UI Skill".to_string()), - short_description: None, - icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")), - icon_large: None, - brand_color: None, - default_prompt: None, - }), - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn drops_interface_when_icons_are_invalid() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); - let skill_dir = skill_path.parent().expect("skill dir"); - - write_skill_interface_at( - skill_dir, - r#" -{ - "interface": { - "icon_small": "icon.png", - "icon_large": "./assets/../logo.svg" - } -} -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "ui-skill".to_string(), - description: "from json".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::User, - }] - ); - } - - #[cfg(unix)] - fn symlink_dir(target: &Path, link: &Path) { - std::os::unix::fs::symlink(target, link).unwrap(); - } - - #[cfg(unix)] - fn symlink_file(target: &Path, link: &Path) { - std::os::unix::fs::symlink(target, link).unwrap(); - } - - #[tokio::test] - #[cfg(unix)] - async fn loads_skills_via_symlinked_subdir_for_user_scope() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let shared = tempfile::tempdir().expect("tempdir"); - - let shared_skill_path = write_skill_at(shared.path(), "demo", "linked-skill", "from link"); - - fs::create_dir_all(codex_home.path().join("skills")).unwrap(); - symlink_dir(shared.path(), &codex_home.path().join("skills/shared")); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "linked-skill".to_string(), - description: "from link".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&shared_skill_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - #[cfg(unix)] - async fn ignores_symlinked_skill_file_for_user_scope() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let shared = tempfile::tempdir().expect("tempdir"); - - let shared_skill_path = - write_skill_at(shared.path(), "demo", "linked-file-skill", "from link"); - - let skill_dir = codex_home.path().join("skills/demo"); - fs::create_dir_all(&skill_dir).unwrap(); - symlink_file(&shared_skill_path, &skill_dir.join(SKILLS_FILENAME)); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills, Vec::new()); - } - - #[tokio::test] - #[cfg(unix)] - async fn does_not_loop_on_symlink_cycle_for_user_scope() { - let codex_home = tempfile::tempdir().expect("tempdir"); - - // Create a cycle: - // $CODEX_HOME/skills/cycle/loop -> $CODEX_HOME/skills/cycle - let cycle_dir = codex_home.path().join("skills/cycle"); - fs::create_dir_all(&cycle_dir).unwrap(); - symlink_dir(&cycle_dir, &cycle_dir.join("loop")); - - let skill_path = write_skill_at(&cycle_dir, "demo", "cycle-skill", "still loads"); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "cycle-skill".to_string(), - description: "still loads".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::User, - }] - ); - } - - #[test] - #[cfg(unix)] - fn loads_skills_via_symlinked_subdir_for_admin_scope() { - let admin_root = tempfile::tempdir().expect("tempdir"); - let shared = tempfile::tempdir().expect("tempdir"); - - let shared_skill_path = - write_skill_at(shared.path(), "demo", "admin-linked-skill", "from link"); - fs::create_dir_all(admin_root.path()).unwrap(); - symlink_dir(shared.path(), &admin_root.path().join("shared")); - - let outcome = load_skills_from_roots([SkillRoot { - path: admin_root.path().to_path_buf(), - scope: SkillScope::Admin, - }]); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "admin-linked-skill".to_string(), - description: "from link".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&shared_skill_path), - scope: SkillScope::Admin, - }] - ); - } - - #[tokio::test] - #[cfg(unix)] - async fn loads_skills_via_symlinked_subdir_for_repo_scope() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let repo_dir = tempfile::tempdir().expect("tempdir"); - mark_as_git_repo(repo_dir.path()); - let shared = tempfile::tempdir().expect("tempdir"); - - let linked_skill_path = - write_skill_at(shared.path(), "demo", "repo-linked-skill", "from link"); - let repo_skills_root = repo_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME); - fs::create_dir_all(&repo_skills_root).unwrap(); - symlink_dir(shared.path(), &repo_skills_root.join("shared")); - - let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "repo-linked-skill".to_string(), - description: "from link".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&linked_skill_path), - scope: SkillScope::Repo, - }] - ); - } - - #[tokio::test] - #[cfg(unix)] - async fn system_scope_ignores_symlinked_subdir() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let shared = tempfile::tempdir().expect("tempdir"); - - write_skill_at(shared.path(), "demo", "system-linked-skill", "from link"); - - let system_root = codex_home.path().join("skills/.system"); - fs::create_dir_all(&system_root).unwrap(); - symlink_dir(shared.path(), &system_root.join("shared")); - - let outcome = load_skills_from_roots([SkillRoot { - path: system_root, - scope: SkillScope::System, - }]); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 0); - } - - #[tokio::test] - async fn respects_max_scan_depth_for_user_scope() { - let codex_home = tempfile::tempdir().expect("tempdir"); - - let within_depth_path = write_skill( - &codex_home, - "d0/d1/d2/d3/d4/d5", - "within-depth-skill", - "loads", - ); - let _too_deep_path = write_skill( - &codex_home, - "d0/d1/d2/d3/d4/d5/d6", - "too-deep-skill", - "should not load", - ); - - let skills_root = codex_home.path().join("skills"); - let outcome = load_skills_from_roots([SkillRoot { - path: skills_root, - scope: SkillScope::User, - }]); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "within-depth-skill".to_string(), - description: "loads".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&within_depth_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn loads_valid_skill() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "demo-skill", "does things\ncarefully"); - let cfg = make_config(&codex_home).await; - - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "demo-skill".to_string(), - description: "does things carefully".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn falls_back_to_directory_name_when_skill_name_is_missing() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_raw_skill_at( - &codex_home.path().join("skills"), - "directory-derived", - "description: fallback name", - ); - let cfg = make_config(&codex_home).await; - - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "directory-derived".to_string(), - description: "fallback name".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn namespaces_plugin_skills_using_plugin_name() { - let root = tempfile::tempdir().expect("tempdir"); - let plugin_root = root.path().join("plugins/sample"); - let skill_path = write_raw_skill_at( - &plugin_root.join("skills"), - "sample-search", - "description: search sample data", - ); - fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); - fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ) - .unwrap(); - - let outcome = load_skills_from_roots([SkillRoot { - path: plugin_root.join("skills"), - scope: SkillScope::User, - }]); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "sample:sample-search".to_string(), - description: "search sample data".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn loads_short_description_from_metadata() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_dir = codex_home.path().join("skills/demo"); - fs::create_dir_all(&skill_dir).unwrap(); - let contents = "---\nname: demo-skill\ndescription: long description\nmetadata:\n short-description: short summary\n---\n\n# Body\n"; - let skill_path = skill_dir.join(SKILLS_FILENAME); - fs::write(&skill_path, contents).unwrap(); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "demo-skill".to_string(), - description: "long description".to_string(), - short_description: Some("short summary".to_string()), - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn enforces_short_description_length_limits() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_dir = codex_home.path().join("skills/demo"); - fs::create_dir_all(&skill_dir).unwrap(); - let too_long = "x".repeat(MAX_SHORT_DESCRIPTION_LEN + 1); - let contents = format!( - "---\nname: demo-skill\ndescription: long description\nmetadata:\n short-description: {too_long}\n---\n\n# Body\n" - ); - fs::write(skill_dir.join(SKILLS_FILENAME), contents).unwrap(); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - assert_eq!(outcome.skills.len(), 0); - assert_eq!(outcome.errors.len(), 1); - assert!( - outcome.errors[0] - .message - .contains("invalid metadata.short-description"), - "expected length error, got: {:?}", - outcome.errors - ); - } - - #[tokio::test] - async fn skips_hidden_and_invalid() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let hidden_dir = codex_home.path().join("skills/.hidden"); - fs::create_dir_all(&hidden_dir).unwrap(); - fs::write( - hidden_dir.join(SKILLS_FILENAME), - "---\nname: hidden\ndescription: hidden\n---\n", - ) - .unwrap(); - - // Invalid because missing closing frontmatter. - let invalid_dir = codex_home.path().join("skills/invalid"); - fs::create_dir_all(&invalid_dir).unwrap(); - fs::write(invalid_dir.join(SKILLS_FILENAME), "---\nname: bad").unwrap(); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - assert_eq!(outcome.skills.len(), 0); - assert_eq!(outcome.errors.len(), 1); - assert!( - outcome.errors[0] - .message - .contains("missing YAML frontmatter"), - "expected frontmatter error" - ); - } - - #[tokio::test] - async fn enforces_length_limits() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let max_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN); - write_skill(&codex_home, "max-len", "max-len", &max_desc); - let cfg = make_config(&codex_home).await; - - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 1); - - let too_long_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN + 1); - write_skill(&codex_home, "too-long", "too-long", &too_long_desc); - let outcome = load_skills_for_test(&cfg); - assert_eq!(outcome.skills.len(), 1); - assert_eq!(outcome.errors.len(), 1); - assert!( - outcome.errors[0].message.contains("invalid description"), - "expected length error" - ); - } - - #[tokio::test] - async fn loads_skills_from_repo_root() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let repo_dir = tempfile::tempdir().expect("tempdir"); - mark_as_git_repo(repo_dir.path()); - - let skills_root = repo_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME); - let skill_path = write_skill_at(&skills_root, "repo", "repo-skill", "from repo"); - let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; - - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "repo-skill".to_string(), - description: "from repo".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::Repo, - }] - ); - } - - #[tokio::test] - async fn loads_skills_from_agents_dir_without_codex_dir() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let repo_dir = tempfile::tempdir().expect("tempdir"); - mark_as_git_repo(repo_dir.path()); - - let skill_path = write_skill_at( - &repo_dir.path().join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME), - "agents", - "agents-skill", - "from agents", - ); - let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; - - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "agents-skill".to_string(), - description: "from agents".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::Repo, - }] - ); - } - - #[tokio::test] - async fn loads_skills_from_all_codex_dirs_under_project_root() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let repo_dir = tempfile::tempdir().expect("tempdir"); - mark_as_git_repo(repo_dir.path()); - - let nested_dir = repo_dir.path().join("nested/inner"); - fs::create_dir_all(&nested_dir).unwrap(); - - let root_skill_path = write_skill_at( - &repo_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "root", - "root-skill", - "from root", - ); - let nested_skill_path = write_skill_at( - &repo_dir - .path() - .join("nested") - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "nested", - "nested-skill", - "from nested", - ); - - let cfg = make_config_for_cwd(&codex_home, nested_dir).await; - - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![ - SkillMetadata { - name: "nested-skill".to_string(), - description: "from nested".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&nested_skill_path), - scope: SkillScope::Repo, - }, - SkillMetadata { - name: "root-skill".to_string(), - description: "from root".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&root_skill_path), - scope: SkillScope::Repo, - }, - ] - ); - } - - #[tokio::test] - async fn loads_skills_from_codex_dir_when_not_git_repo() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let work_dir = tempfile::tempdir().expect("tempdir"); - - let skill_path = write_skill_at( - &work_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "local", - "local-skill", - "from cwd", - ); - - let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await; - - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "local-skill".to_string(), - description: "from cwd".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::Repo, - }] - ); - } - - #[tokio::test] - async fn deduplicates_by_path_preferring_first_root() { - let root = tempfile::tempdir().expect("tempdir"); - - let skill_path = write_skill_at(root.path(), "dupe", "dupe-skill", "from repo"); - - let outcome = load_skills_from_roots([ - SkillRoot { - path: root.path().to_path_buf(), - scope: SkillScope::Repo, - }, - SkillRoot { - path: root.path().to_path_buf(), - scope: SkillScope::User, - }, - ]); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from repo".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::Repo, - }] - ); - } - - #[tokio::test] - async fn keeps_duplicate_names_from_repo_and_user() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let repo_dir = tempfile::tempdir().expect("tempdir"); - mark_as_git_repo(repo_dir.path()); - - let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user"); - let repo_skill_path = write_skill_at( - &repo_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "repo", - "dupe-skill", - "from repo", - ); - - let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; - - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![ - SkillMetadata { - name: "dupe-skill".to_string(), - description: "from repo".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&repo_skill_path), - scope: SkillScope::Repo, - }, - SkillMetadata { - name: "dupe-skill".to_string(), - description: "from user".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&user_skill_path), - scope: SkillScope::User, - }, - ] - ); - } - - #[tokio::test] - async fn keeps_duplicate_names_from_nested_codex_dirs() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let repo_dir = tempfile::tempdir().expect("tempdir"); - mark_as_git_repo(repo_dir.path()); - - let nested_dir = repo_dir.path().join("nested/inner"); - fs::create_dir_all(&nested_dir).unwrap(); - - let root_skill_path = write_skill_at( - &repo_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "root", - "dupe-skill", - "from root", - ); - let nested_skill_path = write_skill_at( - &repo_dir - .path() - .join("nested") - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "nested", - "dupe-skill", - "from nested", - ); - - let cfg = make_config_for_cwd(&codex_home, nested_dir).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - let root_path = - canonicalize_path(&root_skill_path).unwrap_or_else(|_| root_skill_path.clone()); - let nested_path = - canonicalize_path(&nested_skill_path).unwrap_or_else(|_| nested_skill_path.clone()); - let (first_path, second_path, first_description, second_description) = - if root_path <= nested_path { - (root_path, nested_path, "from root", "from nested") - } else { - (nested_path, root_path, "from nested", "from root") - }; - assert_eq!( - outcome.skills, - vec![ - SkillMetadata { - name: "dupe-skill".to_string(), - description: first_description.to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: first_path, - scope: SkillScope::Repo, - }, - SkillMetadata { - name: "dupe-skill".to_string(), - description: second_description.to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: second_path, - scope: SkillScope::Repo, - }, - ] - ); - } - - #[tokio::test] - async fn repo_skills_search_does_not_escape_repo_root() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let outer_dir = tempfile::tempdir().expect("tempdir"); - let repo_dir = outer_dir.path().join("repo"); - fs::create_dir_all(&repo_dir).unwrap(); - - let _skill_path = write_skill_at( - &outer_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "outer", - "outer-skill", - "from outer", - ); - mark_as_git_repo(&repo_dir); - - let cfg = make_config_for_cwd(&codex_home, repo_dir).await; - - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 0); - } - - #[tokio::test] - async fn loads_skills_when_cwd_is_file_in_repo() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let repo_dir = tempfile::tempdir().expect("tempdir"); - mark_as_git_repo(repo_dir.path()); - - let skill_path = write_skill_at( - &repo_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "repo", - "repo-skill", - "from repo", - ); - let file_path = repo_dir.path().join("some-file.txt"); - fs::write(&file_path, "contents").unwrap(); - - let cfg = make_config_for_cwd(&codex_home, file_path).await; - - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "repo-skill".to_string(), - description: "from repo".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::Repo, - }] - ); - } - - #[tokio::test] - async fn non_git_repo_skills_search_does_not_walk_parents() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let outer_dir = tempfile::tempdir().expect("tempdir"); - let nested_dir = outer_dir.path().join("nested/inner"); - fs::create_dir_all(&nested_dir).unwrap(); - - write_skill_at( - &outer_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "outer", - "outer-skill", - "from outer", - ); - - let cfg = make_config_for_cwd(&codex_home, nested_dir).await; - - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 0); - } - - #[tokio::test] - async fn loads_skills_from_system_cache_when_present() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let work_dir = tempfile::tempdir().expect("tempdir"); - - let skill_path = write_system_skill(&codex_home, "system", "system-skill", "from system"); - - let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await; - - let outcome = load_skills_for_test(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "system-skill".to_string(), - description: "from system".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - permission_profile: None, - path_to_skills_md: normalized(&skill_path), - scope: SkillScope::System, - }] - ); - } - - #[tokio::test] - async fn skill_roots_include_admin_with_lowest_priority() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let cfg = make_config(&codex_home).await; - - let scopes: Vec = - super::skill_roots(&cfg.config_layer_stack, &cfg.cwd, Vec::new()) - .into_iter() - .map(|root| root.scope) - .collect(); - let mut expected = vec![SkillScope::User, SkillScope::System]; - if home_dir().is_some() { - expected.insert(1, SkillScope::User); - } - expected.push(SkillScope::Admin); - assert_eq!(scopes, expected); - } -} +#[path = "loader_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/skills/loader_tests.rs b/codex-rs/core/src/skills/loader_tests.rs new file mode 100644 index 0000000000..feee09a9ab --- /dev/null +++ b/codex-rs/core/src/skills/loader_tests.rs @@ -0,0 +1,1898 @@ +use super::*; +use crate::config::ConfigBuilder; +use crate::config::ConfigOverrides; +use crate::config::ConfigToml; +use crate::config::ProjectConfig; +use crate::config_loader::ConfigLayerEntry; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigRequirements; +use crate::config_loader::ConfigRequirementsToml; +use codex_config::CONFIG_TOML_FILE; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsContactsPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::SkillScope; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; +use tempfile::TempDir; +use toml::Value as TomlValue; + +const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex"; + +async fn make_config(codex_home: &TempDir) -> Config { + make_config_for_cwd(codex_home, codex_home.path().to_path_buf()).await +} + +async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config { + let trust_root = cwd + .ancestors() + .find(|ancestor| ancestor.join(".git").exists()) + .map(Path::to_path_buf) + .unwrap_or_else(|| cwd.clone()); + + fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some(HashMap::from([( + trust_root.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }) + .expect("serialize config"), + ) + .unwrap(); + + let harness_overrides = ConfigOverrides { + cwd: Some(cwd), + ..Default::default() + }; + + ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(harness_overrides) + .build() + .await + .expect("defaults for test should always succeed") +} + +fn load_skills_for_test(config: &Config) -> SkillLoadOutcome { + // Keep unit tests hermetic by never scanning the real `$HOME/.agents/skills`. + super::load_skills_from_roots(super::skill_roots_with_home_dir( + &config.config_layer_stack, + &config.cwd, + None, + Vec::new(), + )) +} + +fn mark_as_git_repo(dir: &Path) { + // Config/project-root discovery only checks for the presence of `.git` (file or dir), + // so we can avoid shelling out to `git init` in tests. + fs::write(dir.join(".git"), "gitdir: fake\n").unwrap(); +} + +fn normalized(path: &Path) -> PathBuf { + canonicalize_path(path).unwrap_or_else(|_| path.to_path_buf()) +} + +#[test] +fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_system_to_admin() +-> anyhow::Result<()> { + let tmp = tempfile::tempdir()?; + + let system_folder = tmp.path().join("etc/codex"); + let home_folder = tmp.path().join("home"); + let user_folder = home_folder.join("codex"); + fs::create_dir_all(&system_folder)?; + fs::create_dir_all(&user_folder)?; + + // The file path doesn't need to exist; it's only used to derive the config folder. + let system_file = AbsolutePathBuf::from_absolute_path(system_folder.join("config.toml"))?; + let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?; + + let layers = vec![ + ConfigLayerEntry::new( + ConfigLayerSource::System { file: system_file }, + TomlValue::Table(toml::map::Map::new()), + ), + ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + TomlValue::Table(toml::map::Map::new()), + ), + ]; + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let got = skill_roots_from_layer_stack(&stack, Some(&home_folder)) + .into_iter() + .map(|root| (root.scope, root.path)) + .collect::>(); + + assert_eq!( + got, + vec![ + (SkillScope::User, user_folder.join("skills")), + ( + SkillScope::User, + home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME) + ), + ( + SkillScope::System, + user_folder.join("skills").join(".system") + ), + (SkillScope::Admin, system_folder.join("skills")), + ] + ); + + Ok(()) +} + +#[test] +fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Result<()> { + let tmp = tempfile::tempdir()?; + + let home_folder = tmp.path().join("home"); + let user_folder = home_folder.join("codex"); + fs::create_dir_all(&user_folder)?; + + let project_root = tmp.path().join("repo"); + let dot_codex = project_root.join(".codex"); + fs::create_dir_all(&dot_codex)?; + + let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?; + let project_dot_codex = AbsolutePathBuf::from_absolute_path(&dot_codex)?; + + let layers = vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + TomlValue::Table(toml::map::Map::new()), + ), + ConfigLayerEntry::new_disabled( + ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex, + }, + TomlValue::Table(toml::map::Map::new()), + "marked untrusted", + ), + ]; + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let got = skill_roots_from_layer_stack(&stack, Some(&home_folder)) + .into_iter() + .map(|root| (root.scope, root.path)) + .collect::>(); + + assert_eq!( + got, + vec![ + (SkillScope::Repo, dot_codex.join("skills")), + (SkillScope::User, user_folder.join("skills")), + ( + SkillScope::User, + home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME) + ), + ( + SkillScope::System, + user_folder.join("skills").join(".system") + ), + ] + ); + + Ok(()) +} + +#[test] +fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> { + let tmp = tempfile::tempdir()?; + + let home_folder = tmp.path().join("home"); + let user_folder = home_folder.join("codex"); + fs::create_dir_all(&user_folder)?; + + let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?; + let layers = vec![ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + TomlValue::Table(toml::map::Map::new()), + )]; + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let skill_path = write_skill_at( + &home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME), + "agents-home", + "agents-home-skill", + "from home agents", + ); + + let outcome = load_skills_from_roots(skill_roots_from_layer_stack(&stack, Some(&home_folder))); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "agents-home-skill".to_string(), + description: "from home agents".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + }] + ); + + Ok(()) +} + +fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf { + write_skill_at(&codex_home.path().join("skills"), dir, name, description) +} + +fn write_system_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf { + write_skill_at( + &codex_home.path().join("skills/.system"), + dir, + name, + description, + ) +} + +fn write_skill_at(root: &Path, dir: &str, name: &str, description: &str) -> PathBuf { + let skill_dir = root.join(dir); + fs::create_dir_all(&skill_dir).unwrap(); + let indented_description = description.replace('\n', "\n "); + let content = + format!("---\nname: {name}\ndescription: |-\n {indented_description}\n---\n\n# Body\n"); + let path = skill_dir.join(SKILLS_FILENAME); + fs::write(&path, content).unwrap(); + path +} + +fn write_raw_skill_at(root: &Path, dir: &str, frontmatter: &str) -> PathBuf { + let skill_dir = root.join(dir); + fs::create_dir_all(&skill_dir).unwrap(); + let path = skill_dir.join(SKILLS_FILENAME); + let content = format!("---\n{frontmatter}\n---\n\n# Body\n"); + fs::write(&path, content).unwrap(); + path +} + +fn write_skill_metadata_at(skill_dir: &Path, contents: &str) -> PathBuf { + let path = skill_dir + .join(SKILLS_METADATA_DIR) + .join(SKILLS_METADATA_FILENAME); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, contents).unwrap(); + path +} + +fn write_skill_interface_at(skill_dir: &Path, contents: &str) -> PathBuf { + write_skill_metadata_at(skill_dir, contents) +} + +#[tokio::test] +async fn loads_skill_dependencies_metadata_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "dep-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +{ + "dependencies": { + "tools": [ + { + "type": "env_var", + "value": "GITHUB_TOKEN", + "description": "GitHub API token with repo scopes" + }, + { + "type": "mcp", + "value": "github", + "description": "GitHub MCP server", + "transport": "streamable_http", + "url": "https://example.com/mcp" + }, + { + "type": "cli", + "value": "gh", + "description": "GitHub CLI" + }, + { + "type": "mcp", + "value": "local-gh", + "description": "Local GH MCP server", + "transport": "stdio", + "command": "gh-mcp" + } + ] + } +} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "dep-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: None, + dependencies: Some(SkillDependencies { + tools: vec![ + SkillToolDependency { + r#type: "env_var".to_string(), + value: "GITHUB_TOKEN".to_string(), + description: Some("GitHub API token with repo scopes".to_string()), + transport: None, + command: None, + url: None, + }, + SkillToolDependency { + r#type: "mcp".to_string(), + value: "github".to_string(), + description: Some("GitHub MCP server".to_string()), + transport: Some("streamable_http".to_string()), + command: None, + url: Some("https://example.com/mcp".to_string()), + }, + SkillToolDependency { + r#type: "cli".to_string(), + value: "gh".to_string(), + description: Some("GitHub CLI".to_string()), + transport: None, + command: None, + url: None, + }, + SkillToolDependency { + r#type: "mcp".to_string(), + value: "local-gh".to_string(), + description: Some("Local GH MCP server".to_string()), + transport: Some("stdio".to_string()), + command: Some("gh-mcp".to_string()), + url: None, + }, + ], + }), + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + }] + ); +} + +#[tokio::test] +async fn loads_skill_interface_metadata_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + let normalized_skill_dir = normalized(skill_dir); + + write_skill_interface_at( + skill_dir, + r##" +interface: + display_name: "UI Skill" + short_description: " short desc " + icon_small: "./assets/small-400px.png" + icon_large: "./assets/large-logo.svg" + brand_color: "#3B82F6" + default_prompt: " default prompt " +"##, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + let user_skills: Vec = outcome + .skills + .into_iter() + .filter(|skill| skill.scope == SkillScope::User) + .collect(); + assert_eq!( + user_skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("UI Skill".to_string()), + short_description: Some("short desc".to_string()), + icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")), + icon_large: Some(normalized_skill_dir.join("assets/large-logo.svg")), + brand_color: Some("#3B82F6".to_string()), + default_prompt: Some("default prompt".to_string()), + }), + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(skill_path.as_path()), + scope: SkillScope::User, + }] + ); +} + +#[tokio::test] +async fn loads_skill_policy_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "policy-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +policy: + allow_implicit_invocation: false +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!( + outcome.skills[0].policy, + Some(SkillPolicy { + allow_implicit_invocation: Some(false), + }) + ); + assert!(outcome.allowed_skills_for_implicit_invocation().is_empty()); +} + +#[tokio::test] +async fn empty_skill_policy_defaults_to_allow_implicit_invocation() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "policy-empty", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +policy: {} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!( + outcome.skills[0].policy, + Some(SkillPolicy { + allow_implicit_invocation: None, + }) + ); + assert_eq!( + outcome.allowed_skills_for_implicit_invocation(), + outcome.skills + ); +} + +#[tokio::test] +async fn loads_skill_permissions_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-skill", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + fs::create_dir_all(skill_dir.join("data")).expect("create read path"); + fs::create_dir_all(skill_dir.join("output")).expect("create write path"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: + network: + enabled: true + file_system: + read: + - "./data" + write: + - "./output" +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!( + outcome.skills[0].permission_profile, + Some(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: Some(vec![ + AbsolutePathBuf::try_from(normalized(skill_dir.join("data").as_path())) + .expect("absolute data path"), + ]), + write: Some(vec![ + AbsolutePathBuf::try_from(normalized(skill_dir.join("output").as_path())) + .expect("absolute output path"), + ]), + }), + macos: None, + }) + ); +} + +#[tokio::test] +async fn empty_skill_permissions_do_not_create_profile() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-empty", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: {} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!(outcome.skills[0].permission_profile, None); +} + +#[test] +fn skill_metadata_parses_macos_permissions_yaml() { + let parsed = serde_yaml::from_str::( + r#" +permissions: + macos: + macos_preferences: "read_write" + macos_automation: + - "com.apple.Notes" + macos_launch_services: true + macos_accessibility: true + macos_calendar: true +"#, + ) + .expect("parse skill metadata"); + + assert_eq!( + parsed.permissions, + Some(PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_launch_services: true, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::None, + }), + ..Default::default() + }) + ); +} + +#[test] +fn skill_metadata_parses_macos_reminders_permission_yaml() { + let parsed = serde_yaml::from_str::( + r#" +permissions: + macos: + macos_reminders: true +"#, + ) + .expect("parse reminders skill metadata"); + + assert_eq!( + parsed.permissions, + Some(PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadOnly, + macos_automation: MacOsAutomationPermission::None, + macos_launch_services: false, + macos_accessibility: false, + macos_calendar: false, + macos_reminders: true, + macos_contacts: MacOsContactsPermission::None, + }), + ..Default::default() + }) + ); +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn loads_skill_macos_permissions_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-macos", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: + macos: + macos_preferences: "read_write" + macos_automation: + - "com.apple.Notes" + macos_launch_services: true + macos_accessibility: true + macos_calendar: true +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!( + outcome.skills[0].permission_profile, + Some(PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string() + ],), + macos_launch_services: true, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::None, + }), + ..Default::default() + }) + ); +} + +#[cfg(not(target_os = "macos"))] +#[tokio::test] +async fn loads_skill_macos_permissions_from_yaml_non_macos_does_not_create_profile() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-macos", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: + macos: + macos_preferences: "read_write" + macos_automation: + - "com.apple.Notes" + macos_launch_services: true + macos_accessibility: true + macos_calendar: true +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!( + outcome.skills[0].permission_profile, + Some(PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string() + ],), + macos_launch_services: true, + macos_accessibility: true, + macos_calendar: true, + macos_reminders: false, + macos_contacts: MacOsContactsPermission::None, + }), + ..Default::default() + }) + ); +} + +#[tokio::test] +async fn accepts_icon_paths_under_assets_dir() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + let normalized_skill_dir = normalized(skill_dir); + + write_skill_interface_at( + skill_dir, + r#" +{ + "interface": { + "display_name": "UI Skill", + "icon_small": "assets/icon.png", + "icon_large": "./assets/logo.svg" + } +} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("UI Skill".to_string()), + short_description: None, + icon_small: Some(normalized_skill_dir.join("assets/icon.png")), + icon_large: Some(normalized_skill_dir.join("assets/logo.svg")), + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + }] + ); +} + +#[tokio::test] +async fn ignores_invalid_brand_color() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_interface_at( + skill_dir, + r#" +{ + "interface": { + "brand_color": "blue" + } +} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + }] + ); +} + +#[tokio::test] +async fn ignores_default_prompt_over_max_length() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + let normalized_skill_dir = normalized(skill_dir); + let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1); + + write_skill_interface_at( + skill_dir, + &format!( + r##" +{{ + "interface": {{ + "display_name": "UI Skill", + "icon_small": "./assets/small-400px.png", + "default_prompt": "{too_long}" + }} +}} +"## + ), + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("UI Skill".to_string()), + short_description: None, + icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")), + icon_large: None, + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + }] + ); +} + +#[tokio::test] +async fn drops_interface_when_icons_are_invalid() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_interface_at( + skill_dir, + r#" +{ + "interface": { + "icon_small": "icon.png", + "icon_large": "./assets/../logo.svg" + } +} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + }] + ); +} + +#[cfg(unix)] +fn symlink_dir(target: &Path, link: &Path) { + std::os::unix::fs::symlink(target, link).unwrap(); +} + +#[cfg(unix)] +fn symlink_file(target: &Path, link: &Path) { + std::os::unix::fs::symlink(target, link).unwrap(); +} + +#[tokio::test] +#[cfg(unix)] +async fn loads_skills_via_symlinked_subdir_for_user_scope() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let shared = tempfile::tempdir().expect("tempdir"); + + let shared_skill_path = write_skill_at(shared.path(), "demo", "linked-skill", "from link"); + + fs::create_dir_all(codex_home.path().join("skills")).unwrap(); + symlink_dir(shared.path(), &codex_home.path().join("skills/shared")); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "linked-skill".to_string(), + description: "from link".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&shared_skill_path), + scope: SkillScope::User, + }] + ); +} + +#[tokio::test] +#[cfg(unix)] +async fn ignores_symlinked_skill_file_for_user_scope() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let shared = tempfile::tempdir().expect("tempdir"); + + let shared_skill_path = write_skill_at(shared.path(), "demo", "linked-file-skill", "from link"); + + let skill_dir = codex_home.path().join("skills/demo"); + fs::create_dir_all(&skill_dir).unwrap(); + symlink_file(&shared_skill_path, &skill_dir.join(SKILLS_FILENAME)); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills, Vec::new()); +} + +#[tokio::test] +#[cfg(unix)] +async fn does_not_loop_on_symlink_cycle_for_user_scope() { + let codex_home = tempfile::tempdir().expect("tempdir"); + + // Create a cycle: + // $CODEX_HOME/skills/cycle/loop -> $CODEX_HOME/skills/cycle + let cycle_dir = codex_home.path().join("skills/cycle"); + fs::create_dir_all(&cycle_dir).unwrap(); + symlink_dir(&cycle_dir, &cycle_dir.join("loop")); + + let skill_path = write_skill_at(&cycle_dir, "demo", "cycle-skill", "still loads"); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "cycle-skill".to_string(), + description: "still loads".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + }] + ); +} + +#[test] +#[cfg(unix)] +fn loads_skills_via_symlinked_subdir_for_admin_scope() { + let admin_root = tempfile::tempdir().expect("tempdir"); + let shared = tempfile::tempdir().expect("tempdir"); + + let shared_skill_path = + write_skill_at(shared.path(), "demo", "admin-linked-skill", "from link"); + fs::create_dir_all(admin_root.path()).unwrap(); + symlink_dir(shared.path(), &admin_root.path().join("shared")); + + let outcome = load_skills_from_roots([SkillRoot { + path: admin_root.path().to_path_buf(), + scope: SkillScope::Admin, + }]); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "admin-linked-skill".to_string(), + description: "from link".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&shared_skill_path), + scope: SkillScope::Admin, + }] + ); +} + +#[tokio::test] +#[cfg(unix)] +async fn loads_skills_via_symlinked_subdir_for_repo_scope() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + let shared = tempfile::tempdir().expect("tempdir"); + + let linked_skill_path = write_skill_at(shared.path(), "demo", "repo-linked-skill", "from link"); + let repo_skills_root = repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME); + fs::create_dir_all(&repo_skills_root).unwrap(); + symlink_dir(shared.path(), &repo_skills_root.join("shared")); + + let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "repo-linked-skill".to_string(), + description: "from link".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&linked_skill_path), + scope: SkillScope::Repo, + }] + ); +} + +#[tokio::test] +#[cfg(unix)] +async fn system_scope_ignores_symlinked_subdir() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let shared = tempfile::tempdir().expect("tempdir"); + + write_skill_at(shared.path(), "demo", "system-linked-skill", "from link"); + + let system_root = codex_home.path().join("skills/.system"); + fs::create_dir_all(&system_root).unwrap(); + symlink_dir(shared.path(), &system_root.join("shared")); + + let outcome = load_skills_from_roots([SkillRoot { + path: system_root, + scope: SkillScope::System, + }]); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 0); +} + +#[tokio::test] +async fn respects_max_scan_depth_for_user_scope() { + let codex_home = tempfile::tempdir().expect("tempdir"); + + let within_depth_path = write_skill( + &codex_home, + "d0/d1/d2/d3/d4/d5", + "within-depth-skill", + "loads", + ); + let _too_deep_path = write_skill( + &codex_home, + "d0/d1/d2/d3/d4/d5/d6", + "too-deep-skill", + "should not load", + ); + + let skills_root = codex_home.path().join("skills"); + let outcome = load_skills_from_roots([SkillRoot { + path: skills_root, + scope: SkillScope::User, + }]); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "within-depth-skill".to_string(), + description: "loads".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&within_depth_path), + scope: SkillScope::User, + }] + ); +} + +#[tokio::test] +async fn loads_valid_skill() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "demo-skill", "does things\ncarefully"); + let cfg = make_config(&codex_home).await; + + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "demo-skill".to_string(), + description: "does things carefully".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + }] + ); +} + +#[tokio::test] +async fn falls_back_to_directory_name_when_skill_name_is_missing() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_raw_skill_at( + &codex_home.path().join("skills"), + "directory-derived", + "description: fallback name", + ); + let cfg = make_config(&codex_home).await; + + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "directory-derived".to_string(), + description: "fallback name".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + }] + ); +} + +#[tokio::test] +async fn namespaces_plugin_skills_using_plugin_name() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugins/sample"); + let skill_path = write_raw_skill_at( + &plugin_root.join("skills"), + "sample-search", + "description: search sample data", + ); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .unwrap(); + + let outcome = load_skills_from_roots([SkillRoot { + path: plugin_root.join("skills"), + scope: SkillScope::User, + }]); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "sample:sample-search".to_string(), + description: "search sample data".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + }] + ); +} + +#[tokio::test] +async fn loads_short_description_from_metadata() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_dir = codex_home.path().join("skills/demo"); + fs::create_dir_all(&skill_dir).unwrap(); + let contents = "---\nname: demo-skill\ndescription: long description\nmetadata:\n short-description: short summary\n---\n\n# Body\n"; + let skill_path = skill_dir.join(SKILLS_FILENAME); + fs::write(&skill_path, contents).unwrap(); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "demo-skill".to_string(), + description: "long description".to_string(), + short_description: Some("short summary".to_string()), + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + }] + ); +} + +#[tokio::test] +async fn enforces_short_description_length_limits() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_dir = codex_home.path().join("skills/demo"); + fs::create_dir_all(&skill_dir).unwrap(); + let too_long = "x".repeat(MAX_SHORT_DESCRIPTION_LEN + 1); + let contents = format!( + "---\nname: demo-skill\ndescription: long description\nmetadata:\n short-description: {too_long}\n---\n\n# Body\n" + ); + fs::write(skill_dir.join(SKILLS_FILENAME), contents).unwrap(); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + assert_eq!(outcome.skills.len(), 0); + assert_eq!(outcome.errors.len(), 1); + assert!( + outcome.errors[0] + .message + .contains("invalid metadata.short-description"), + "expected length error, got: {:?}", + outcome.errors + ); +} + +#[tokio::test] +async fn skips_hidden_and_invalid() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let hidden_dir = codex_home.path().join("skills/.hidden"); + fs::create_dir_all(&hidden_dir).unwrap(); + fs::write( + hidden_dir.join(SKILLS_FILENAME), + "---\nname: hidden\ndescription: hidden\n---\n", + ) + .unwrap(); + + // Invalid because missing closing frontmatter. + let invalid_dir = codex_home.path().join("skills/invalid"); + fs::create_dir_all(&invalid_dir).unwrap(); + fs::write(invalid_dir.join(SKILLS_FILENAME), "---\nname: bad").unwrap(); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg); + assert_eq!(outcome.skills.len(), 0); + assert_eq!(outcome.errors.len(), 1); + assert!( + outcome.errors[0] + .message + .contains("missing YAML frontmatter"), + "expected frontmatter error" + ); +} + +#[tokio::test] +async fn enforces_length_limits() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let max_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN); + write_skill(&codex_home, "max-len", "max-len", &max_desc); + let cfg = make_config(&codex_home).await; + + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + + let too_long_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN + 1); + write_skill(&codex_home, "too-long", "too-long", &too_long_desc); + let outcome = load_skills_for_test(&cfg); + assert_eq!(outcome.skills.len(), 1); + assert_eq!(outcome.errors.len(), 1); + assert!( + outcome.errors[0].message.contains("invalid description"), + "expected length error" + ); +} + +#[tokio::test] +async fn loads_skills_from_repo_root() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let skills_root = repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME); + let skill_path = write_skill_at(&skills_root, "repo", "repo-skill", "from repo"); + let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; + + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "repo-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::Repo, + }] + ); +} + +#[tokio::test] +async fn loads_skills_from_agents_dir_without_codex_dir() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let skill_path = write_skill_at( + &repo_dir.path().join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME), + "agents", + "agents-skill", + "from agents", + ); + let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; + + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "agents-skill".to_string(), + description: "from agents".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::Repo, + }] + ); +} + +#[tokio::test] +async fn loads_skills_from_all_codex_dirs_under_project_root() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let nested_dir = repo_dir.path().join("nested/inner"); + fs::create_dir_all(&nested_dir).unwrap(); + + let root_skill_path = write_skill_at( + &repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "root", + "root-skill", + "from root", + ); + let nested_skill_path = write_skill_at( + &repo_dir + .path() + .join("nested") + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "nested", + "nested-skill", + "from nested", + ); + + let cfg = make_config_for_cwd(&codex_home, nested_dir).await; + + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![ + SkillMetadata { + name: "nested-skill".to_string(), + description: "from nested".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&nested_skill_path), + scope: SkillScope::Repo, + }, + SkillMetadata { + name: "root-skill".to_string(), + description: "from root".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&root_skill_path), + scope: SkillScope::Repo, + }, + ] + ); +} + +#[tokio::test] +async fn loads_skills_from_codex_dir_when_not_git_repo() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let work_dir = tempfile::tempdir().expect("tempdir"); + + let skill_path = write_skill_at( + &work_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "local", + "local-skill", + "from cwd", + ); + + let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await; + + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "local-skill".to_string(), + description: "from cwd".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::Repo, + }] + ); +} + +#[tokio::test] +async fn deduplicates_by_path_preferring_first_root() { + let root = tempfile::tempdir().expect("tempdir"); + + let skill_path = write_skill_at(root.path(), "dupe", "dupe-skill", "from repo"); + + let outcome = load_skills_from_roots([ + SkillRoot { + path: root.path().to_path_buf(), + scope: SkillScope::Repo, + }, + SkillRoot { + path: root.path().to_path_buf(), + scope: SkillScope::User, + }, + ]); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "dupe-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::Repo, + }] + ); +} + +#[tokio::test] +async fn keeps_duplicate_names_from_repo_and_user() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user"); + let repo_skill_path = write_skill_at( + &repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "repo", + "dupe-skill", + "from repo", + ); + + let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; + + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![ + SkillMetadata { + name: "dupe-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&repo_skill_path), + scope: SkillScope::Repo, + }, + SkillMetadata { + name: "dupe-skill".to_string(), + description: "from user".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&user_skill_path), + scope: SkillScope::User, + }, + ] + ); +} + +#[tokio::test] +async fn keeps_duplicate_names_from_nested_codex_dirs() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let nested_dir = repo_dir.path().join("nested/inner"); + fs::create_dir_all(&nested_dir).unwrap(); + + let root_skill_path = write_skill_at( + &repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "root", + "dupe-skill", + "from root", + ); + let nested_skill_path = write_skill_at( + &repo_dir + .path() + .join("nested") + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "nested", + "dupe-skill", + "from nested", + ); + + let cfg = make_config_for_cwd(&codex_home, nested_dir).await; + let outcome = load_skills_for_test(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + let root_path = canonicalize_path(&root_skill_path).unwrap_or_else(|_| root_skill_path.clone()); + let nested_path = + canonicalize_path(&nested_skill_path).unwrap_or_else(|_| nested_skill_path.clone()); + let (first_path, second_path, first_description, second_description) = + if root_path <= nested_path { + (root_path, nested_path, "from root", "from nested") + } else { + (nested_path, root_path, "from nested", "from root") + }; + assert_eq!( + outcome.skills, + vec![ + SkillMetadata { + name: "dupe-skill".to_string(), + description: first_description.to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: first_path, + scope: SkillScope::Repo, + }, + SkillMetadata { + name: "dupe-skill".to_string(), + description: second_description.to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: second_path, + scope: SkillScope::Repo, + }, + ] + ); +} + +#[tokio::test] +async fn repo_skills_search_does_not_escape_repo_root() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let outer_dir = tempfile::tempdir().expect("tempdir"); + let repo_dir = outer_dir.path().join("repo"); + fs::create_dir_all(&repo_dir).unwrap(); + + let _skill_path = write_skill_at( + &outer_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "outer", + "outer-skill", + "from outer", + ); + mark_as_git_repo(&repo_dir); + + let cfg = make_config_for_cwd(&codex_home, repo_dir).await; + + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 0); +} + +#[tokio::test] +async fn loads_skills_when_cwd_is_file_in_repo() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let skill_path = write_skill_at( + &repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "repo", + "repo-skill", + "from repo", + ); + let file_path = repo_dir.path().join("some-file.txt"); + fs::write(&file_path, "contents").unwrap(); + + let cfg = make_config_for_cwd(&codex_home, file_path).await; + + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "repo-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::Repo, + }] + ); +} + +#[tokio::test] +async fn non_git_repo_skills_search_does_not_walk_parents() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let outer_dir = tempfile::tempdir().expect("tempdir"); + let nested_dir = outer_dir.path().join("nested/inner"); + fs::create_dir_all(&nested_dir).unwrap(); + + write_skill_at( + &outer_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "outer", + "outer-skill", + "from outer", + ); + + let cfg = make_config_for_cwd(&codex_home, nested_dir).await; + + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 0); +} + +#[tokio::test] +async fn loads_skills_from_system_cache_when_present() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let work_dir = tempfile::tempdir().expect("tempdir"); + + let skill_path = write_system_skill(&codex_home, "system", "system-skill", "from system"); + + let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await; + + let outcome = load_skills_for_test(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "system-skill".to_string(), + description: "from system".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::System, + }] + ); +} + +#[tokio::test] +async fn skill_roots_include_admin_with_lowest_priority() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cfg = make_config(&codex_home).await; + + let scopes: Vec = super::skill_roots(&cfg.config_layer_stack, &cfg.cwd, Vec::new()) + .into_iter() + .map(|root| root.scope) + .collect(); + let mut expected = vec![SkillScope::User, SkillScope::System]; + if home_dir().is_some() { + expected.insert(1, SkillScope::User); + } + expected.push(SkillScope::Admin); + assert_eq!(scopes, expected); +} diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index ed7471d653..3a824d25fc 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -279,364 +279,5 @@ fn normalize_extra_user_roots(extra_user_roots: &[PathBuf]) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::config::ConfigBuilder; - use crate::config::ConfigOverrides; - use crate::config_loader::ConfigLayerEntry; - use crate::config_loader::ConfigLayerStack; - use crate::config_loader::ConfigRequirementsToml; - use crate::plugins::PluginsManager; - use pretty_assertions::assert_eq; - use std::fs; - use std::path::PathBuf; - use tempfile::TempDir; - - fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) { - let skill_dir = codex_home.path().join("skills").join(dir); - fs::create_dir_all(&skill_dir).unwrap(); - let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); - fs::write(skill_dir.join("SKILL.md"), content).unwrap(); - } - - #[test] - fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let stale_system_skill_dir = codex_home.path().join("skills/.system/stale-skill"); - fs::create_dir_all(&stale_system_skill_dir).expect("create stale system skill dir"); - fs::write(stale_system_skill_dir.join("SKILL.md"), "# stale\n") - .expect("write stale system skill"); - - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let _skills_manager = - SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, false); - - assert!( - !codex_home.path().join("skills/.system").exists(), - "expected disabling system skills to remove stale cached bundled skills" - ); - } - - #[tokio::test] - async fn skills_for_config_seeds_cache_by_cwd() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let cwd = tempfile::tempdir().expect("tempdir"); - - let cfg = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }) - .build() - .await - .expect("defaults for test should always succeed"); - - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let skills_manager = - SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true); - - write_user_skill(&codex_home, "a", "skill-a", "from a"); - let outcome1 = skills_manager.skills_for_config(&cfg); - assert!( - outcome1.skills.iter().any(|s| s.name == "skill-a"), - "expected skill-a to be discovered" - ); - - // Write a new skill after the first call; the second call should hit the cache and not - // reflect the new file. - write_user_skill(&codex_home, "b", "skill-b", "from b"); - let outcome2 = skills_manager.skills_for_config(&cfg); - assert_eq!(outcome2.errors, outcome1.errors); - assert_eq!(outcome2.skills, outcome1.skills); - } - - #[tokio::test] - async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let cwd = tempfile::tempdir().expect("tempdir"); - let extra_root = tempfile::tempdir().expect("tempdir"); - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }) - .build() - .await - .expect("defaults for test should always succeed"); - - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let skills_manager = - SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true); - let _ = skills_manager.skills_for_config(&config); - - write_user_skill(&extra_root, "x", "extra-skill", "from extra root"); - let extra_root_path = extra_root.path().to_path_buf(); - let outcome_with_extra = skills_manager - .skills_for_cwd_with_extra_user_roots( - cwd.path(), - true, - std::slice::from_ref(&extra_root_path), - ) - .await; - assert!( - outcome_with_extra - .skills - .iter() - .any(|skill| skill.name == "extra-skill") - ); - assert!( - outcome_with_extra - .skills - .iter() - .any(|skill| skill.scope == SkillScope::System) - ); - - // The cwd-only API returns the current cached entry for this cwd, even when that entry - // was produced with extra roots. - let outcome_without_extra = skills_manager.skills_for_cwd(cwd.path(), false).await; - assert_eq!(outcome_without_extra.skills, outcome_with_extra.skills); - assert_eq!(outcome_without_extra.errors, outcome_with_extra.errors); - } - - #[tokio::test] - async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let cwd = tempfile::tempdir().expect("tempdir"); - let bundled_skill_dir = codex_home.path().join("skills/.system/bundled-skill"); - fs::create_dir_all(&bundled_skill_dir).expect("create bundled skill dir"); - fs::write( - bundled_skill_dir.join("SKILL.md"), - "---\nname: bundled-skill\ndescription: from bundled root\n---\n\n# Body\n", - ) - .expect("write bundled skill"); - - fs::write( - codex_home.path().join(crate::config::CONFIG_TOML_FILE), - "[skills.bundled]\nenabled = false\n", - ) - .expect("write config"); - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }) - .build() - .await - .expect("load config"); - - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let skills_manager = SkillsManager::new( - codex_home.path().to_path_buf(), - plugins_manager, - config.bundled_skills_enabled(), - ); - - // Recreate the cached bundled skill after startup cleanup so this assertion exercises - // root selection rather than relying on directory removal succeeding. - fs::create_dir_all(&bundled_skill_dir).expect("recreate bundled skill dir"); - fs::write( - bundled_skill_dir.join("SKILL.md"), - "---\nname: bundled-skill\ndescription: from bundled root\n---\n\n# Body\n", - ) - .expect("rewrite bundled skill"); - - let outcome = skills_manager.skills_for_config(&config); - assert!( - outcome - .skills - .iter() - .all(|skill| skill.name != "bundled-skill") - ); - assert!( - outcome - .skills - .iter() - .all(|skill| skill.scope != SkillScope::System) - ); - } - - #[tokio::test] - async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let cwd = tempfile::tempdir().expect("tempdir"); - let extra_root_a = tempfile::tempdir().expect("tempdir"); - let extra_root_b = tempfile::tempdir().expect("tempdir"); - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }) - .build() - .await - .expect("defaults for test should always succeed"); - - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let skills_manager = - SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true); - let _ = skills_manager.skills_for_config(&config); - - write_user_skill(&extra_root_a, "x", "extra-skill-a", "from extra root a"); - write_user_skill(&extra_root_b, "x", "extra-skill-b", "from extra root b"); - - let extra_root_a_path = extra_root_a.path().to_path_buf(); - let outcome_a = skills_manager - .skills_for_cwd_with_extra_user_roots( - cwd.path(), - true, - std::slice::from_ref(&extra_root_a_path), - ) - .await; - assert!( - outcome_a - .skills - .iter() - .any(|skill| skill.name == "extra-skill-a") - ); - assert!( - outcome_a - .skills - .iter() - .all(|skill| skill.name != "extra-skill-b") - ); - - let extra_root_b_path = extra_root_b.path().to_path_buf(); - let outcome_b = skills_manager - .skills_for_cwd_with_extra_user_roots( - cwd.path(), - false, - std::slice::from_ref(&extra_root_b_path), - ) - .await; - assert!( - outcome_b - .skills - .iter() - .any(|skill| skill.name == "extra-skill-a") - ); - assert!( - outcome_b - .skills - .iter() - .all(|skill| skill.name != "extra-skill-b") - ); - - let outcome_reloaded = skills_manager - .skills_for_cwd_with_extra_user_roots( - cwd.path(), - true, - std::slice::from_ref(&extra_root_b_path), - ) - .await; - assert!( - outcome_reloaded - .skills - .iter() - .any(|skill| skill.name == "extra-skill-b") - ); - assert!( - outcome_reloaded - .skills - .iter() - .all(|skill| skill.name != "extra-skill-a") - ); - } - - #[test] - fn normalize_extra_user_roots_is_stable_for_equivalent_inputs() { - let a = PathBuf::from("/tmp/a"); - let b = PathBuf::from("/tmp/b"); - - let first = normalize_extra_user_roots(&[a.clone(), b.clone(), a.clone()]); - let second = normalize_extra_user_roots(&[b, a]); - - assert_eq!(first, second); - } - - #[cfg_attr(windows, ignore)] - #[test] - fn disabled_paths_from_stack_allows_session_flags_to_override_user_layer() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); - let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) - .expect("user config path should be absolute"); - let user_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, - toml::from_str(&format!( - r#"[[skills.config]] -path = "{}" -enabled = false -"#, - skill_path.display() - )) - .expect("user layer toml"), - ); - let session_layer = ConfigLayerEntry::new( - ConfigLayerSource::SessionFlags, - toml::from_str(&format!( - r#"[[skills.config]] -path = "{}" -enabled = true -"#, - skill_path.display() - )) - .expect("session layer toml"), - ); - let stack = ConfigLayerStack::new( - vec![user_layer, session_layer], - Default::default(), - ConfigRequirementsToml::default(), - ) - .expect("valid config layer stack"); - - assert_eq!(disabled_paths_from_stack(&stack), HashSet::new()); - } - - #[cfg_attr(windows, ignore)] - #[test] - fn disabled_paths_from_stack_allows_session_flags_to_disable_user_enabled_skill() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); - let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) - .expect("user config path should be absolute"); - let user_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, - toml::from_str(&format!( - r#"[[skills.config]] -path = "{}" -enabled = true -"#, - skill_path.display() - )) - .expect("user layer toml"), - ); - let session_layer = ConfigLayerEntry::new( - ConfigLayerSource::SessionFlags, - toml::from_str(&format!( - r#"[[skills.config]] -path = "{}" -enabled = false -"#, - skill_path.display() - )) - .expect("session layer toml"), - ); - let stack = ConfigLayerStack::new( - vec![user_layer, session_layer], - Default::default(), - ConfigRequirementsToml::default(), - ) - .expect("valid config layer stack"); - - assert_eq!( - disabled_paths_from_stack(&stack), - HashSet::from([skill_path]) - ); - } -} +#[path = "manager_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/skills/manager_tests.rs b/codex-rs/core/src/skills/manager_tests.rs new file mode 100644 index 0000000000..f9d6dc2c5f --- /dev/null +++ b/codex-rs/core/src/skills/manager_tests.rs @@ -0,0 +1,356 @@ +use super::*; +use crate::config::ConfigBuilder; +use crate::config::ConfigOverrides; +use crate::config_loader::ConfigLayerEntry; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigRequirementsToml; +use crate::plugins::PluginsManager; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) { + let skill_dir = codex_home.path().join("skills").join(dir); + fs::create_dir_all(&skill_dir).unwrap(); + let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); + fs::write(skill_dir.join("SKILL.md"), content).unwrap(); +} + +#[test] +fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let stale_system_skill_dir = codex_home.path().join("skills/.system/stale-skill"); + fs::create_dir_all(&stale_system_skill_dir).expect("create stale system skill dir"); + fs::write(stale_system_skill_dir.join("SKILL.md"), "# stale\n") + .expect("write stale system skill"); + + let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); + let _skills_manager = + SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, false); + + assert!( + !codex_home.path().join("skills/.system").exists(), + "expected disabling system skills to remove stale cached bundled skills" + ); +} + +#[tokio::test] +async fn skills_for_config_seeds_cache_by_cwd() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + + let cfg = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await + .expect("defaults for test should always succeed"); + + let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); + let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true); + + write_user_skill(&codex_home, "a", "skill-a", "from a"); + let outcome1 = skills_manager.skills_for_config(&cfg); + assert!( + outcome1.skills.iter().any(|s| s.name == "skill-a"), + "expected skill-a to be discovered" + ); + + // Write a new skill after the first call; the second call should hit the cache and not + // reflect the new file. + write_user_skill(&codex_home, "b", "skill-b", "from b"); + let outcome2 = skills_manager.skills_for_config(&cfg); + assert_eq!(outcome2.errors, outcome1.errors); + assert_eq!(outcome2.skills, outcome1.skills); +} + +#[tokio::test] +async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let extra_root = tempfile::tempdir().expect("tempdir"); + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await + .expect("defaults for test should always succeed"); + + let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); + let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true); + let _ = skills_manager.skills_for_config(&config); + + write_user_skill(&extra_root, "x", "extra-skill", "from extra root"); + let extra_root_path = extra_root.path().to_path_buf(); + let outcome_with_extra = skills_manager + .skills_for_cwd_with_extra_user_roots( + cwd.path(), + true, + std::slice::from_ref(&extra_root_path), + ) + .await; + assert!( + outcome_with_extra + .skills + .iter() + .any(|skill| skill.name == "extra-skill") + ); + assert!( + outcome_with_extra + .skills + .iter() + .any(|skill| skill.scope == SkillScope::System) + ); + + // The cwd-only API returns the current cached entry for this cwd, even when that entry + // was produced with extra roots. + let outcome_without_extra = skills_manager.skills_for_cwd(cwd.path(), false).await; + assert_eq!(outcome_without_extra.skills, outcome_with_extra.skills); + assert_eq!(outcome_without_extra.errors, outcome_with_extra.errors); +} + +#[tokio::test] +async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let bundled_skill_dir = codex_home.path().join("skills/.system/bundled-skill"); + fs::create_dir_all(&bundled_skill_dir).expect("create bundled skill dir"); + fs::write( + bundled_skill_dir.join("SKILL.md"), + "---\nname: bundled-skill\ndescription: from bundled root\n---\n\n# Body\n", + ) + .expect("write bundled skill"); + + fs::write( + codex_home.path().join(crate::config::CONFIG_TOML_FILE), + "[skills.bundled]\nenabled = false\n", + ) + .expect("write config"); + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await + .expect("load config"); + + let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); + let skills_manager = SkillsManager::new( + codex_home.path().to_path_buf(), + plugins_manager, + config.bundled_skills_enabled(), + ); + + // Recreate the cached bundled skill after startup cleanup so this assertion exercises + // root selection rather than relying on directory removal succeeding. + fs::create_dir_all(&bundled_skill_dir).expect("recreate bundled skill dir"); + fs::write( + bundled_skill_dir.join("SKILL.md"), + "---\nname: bundled-skill\ndescription: from bundled root\n---\n\n# Body\n", + ) + .expect("rewrite bundled skill"); + + let outcome = skills_manager.skills_for_config(&config); + assert!( + outcome + .skills + .iter() + .all(|skill| skill.name != "bundled-skill") + ); + assert!( + outcome + .skills + .iter() + .all(|skill| skill.scope != SkillScope::System) + ); +} + +#[tokio::test] +async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let extra_root_a = tempfile::tempdir().expect("tempdir"); + let extra_root_b = tempfile::tempdir().expect("tempdir"); + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await + .expect("defaults for test should always succeed"); + + let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); + let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true); + let _ = skills_manager.skills_for_config(&config); + + write_user_skill(&extra_root_a, "x", "extra-skill-a", "from extra root a"); + write_user_skill(&extra_root_b, "x", "extra-skill-b", "from extra root b"); + + let extra_root_a_path = extra_root_a.path().to_path_buf(); + let outcome_a = skills_manager + .skills_for_cwd_with_extra_user_roots( + cwd.path(), + true, + std::slice::from_ref(&extra_root_a_path), + ) + .await; + assert!( + outcome_a + .skills + .iter() + .any(|skill| skill.name == "extra-skill-a") + ); + assert!( + outcome_a + .skills + .iter() + .all(|skill| skill.name != "extra-skill-b") + ); + + let extra_root_b_path = extra_root_b.path().to_path_buf(); + let outcome_b = skills_manager + .skills_for_cwd_with_extra_user_roots( + cwd.path(), + false, + std::slice::from_ref(&extra_root_b_path), + ) + .await; + assert!( + outcome_b + .skills + .iter() + .any(|skill| skill.name == "extra-skill-a") + ); + assert!( + outcome_b + .skills + .iter() + .all(|skill| skill.name != "extra-skill-b") + ); + + let outcome_reloaded = skills_manager + .skills_for_cwd_with_extra_user_roots( + cwd.path(), + true, + std::slice::from_ref(&extra_root_b_path), + ) + .await; + assert!( + outcome_reloaded + .skills + .iter() + .any(|skill| skill.name == "extra-skill-b") + ); + assert!( + outcome_reloaded + .skills + .iter() + .all(|skill| skill.name != "extra-skill-a") + ); +} + +#[test] +fn normalize_extra_user_roots_is_stable_for_equivalent_inputs() { + let a = PathBuf::from("/tmp/a"); + let b = PathBuf::from("/tmp/b"); + + let first = normalize_extra_user_roots(&[a.clone(), b.clone(), a.clone()]); + let second = normalize_extra_user_roots(&[b, a]); + + assert_eq!(first, second); +} + +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_from_stack_allows_session_flags_to_override_user_layer() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str(&format!( + r#"[[skills.config]] +path = "{}" +enabled = false +"#, + skill_path.display() + )) + .expect("user layer toml"), + ); + let session_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(&format!( + r#"[[skills.config]] +path = "{}" +enabled = true +"#, + skill_path.display() + )) + .expect("session layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer, session_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + assert_eq!(disabled_paths_from_stack(&stack), HashSet::new()); +} + +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_from_stack_allows_session_flags_to_disable_user_enabled_skill() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str(&format!( + r#"[[skills.config]] +path = "{}" +enabled = true +"#, + skill_path.display() + )) + .expect("user layer toml"), + ); + let session_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(&format!( + r#"[[skills.config]] +path = "{}" +enabled = false +"#, + skill_path.display() + )) + .expect("session layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer, session_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + assert_eq!( + disabled_paths_from_stack(&stack), + HashSet::from([skill_path]) + ); +} diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 5c0a741a12..851618c00e 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -15,6 +15,7 @@ use crate::models_manager::manager::ModelsManager; use crate::plugins::PluginsManager; use crate::skills::SkillsManager; use crate::state_db::StateDbHandle; +use crate::tools::code_mode::CodeModeService; use crate::tools::network_approval::NetworkApprovalService; use crate::tools::runtimes::ExecveSessionApproval; use crate::tools::sandboxing::ApprovalStore; @@ -22,35 +23,12 @@ use crate::unified_exec::UnifiedExecProcessManager; use codex_hooks::Hooks; use codex_otel::SessionTelemetry; use codex_utils_absolute_path::AbsolutePathBuf; -use serde_json::Value as JsonValue; use std::path::PathBuf; use tokio::sync::Mutex; use tokio::sync::RwLock; use tokio::sync::watch; use tokio_util::sync::CancellationToken; -pub(crate) struct CodeModeStoreService { - stored_values: Mutex>, -} - -impl Default for CodeModeStoreService { - fn default() -> Self { - Self { - stored_values: Mutex::new(HashMap::new()), - } - } -} - -impl CodeModeStoreService { - pub(crate) async fn stored_values(&self) -> HashMap { - self.stored_values.lock().await.clone() - } - - pub(crate) async fn replace_stored_values(&self, values: HashMap) { - *self.stored_values.lock().await = values; - } -} - pub(crate) struct SessionServices { pub(crate) mcp_connection_manager: Arc>, pub(crate) mcp_startup_cancellation_token: Mutex, @@ -82,5 +60,5 @@ pub(crate) struct SessionServices { pub(crate) state_db: Option, /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, - pub(crate) code_mode_store: CodeModeStoreService, + pub(crate) code_mode_service: CodeModeService, } diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 973501e8dd..40faa4b856 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -13,6 +13,7 @@ use crate::error::Result as CodexResult; use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; +use crate::sandboxing::merge_permission_profiles; use crate::tasks::RegularTask; use crate::truncate::TruncationPolicy; use codex_protocol::protocol::TurnContextItem; @@ -31,7 +32,6 @@ pub(crate) struct SessionState { previous_turn_settings: Option, /// Startup regular task pre-created during session initialization. pub(crate) startup_regular_task: Option>>, - pub(crate) active_mcp_tool_selection: Option>, pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, @@ -50,7 +50,6 @@ impl SessionState { mcp_dependency_prompted: HashSet::new(), previous_turn_settings: None, startup_regular_task: None, - active_mcp_tool_selection: None, active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, @@ -176,64 +175,6 @@ impl SessionState { self.startup_regular_task.take() } - pub(crate) fn merge_mcp_tool_selection(&mut self, tool_names: Vec) -> Vec { - if tool_names.is_empty() { - return self.active_mcp_tool_selection.clone().unwrap_or_default(); - } - - let mut merged = self.active_mcp_tool_selection.take().unwrap_or_default(); - let mut seen: HashSet = merged.iter().cloned().collect(); - - for tool_name in tool_names { - if seen.insert(tool_name.clone()) { - merged.push(tool_name); - } - } - - self.active_mcp_tool_selection = Some(merged.clone()); - merged - } - - pub(crate) fn set_mcp_tool_selection(&mut self, tool_names: Vec) { - if tool_names.is_empty() { - self.active_mcp_tool_selection = None; - return; - } - - let mut selected = Vec::new(); - let mut seen = HashSet::new(); - for tool_name in tool_names { - if seen.insert(tool_name.clone()) { - selected.push(tool_name); - } - } - - self.active_mcp_tool_selection = if selected.is_empty() { - None - } else { - Some(selected) - }; - } - - pub(crate) fn get_mcp_tool_selection(&self) -> Option> { - self.active_mcp_tool_selection.clone() - } - - pub(crate) fn clear_mcp_tool_selection(&mut self) { - self.active_mcp_tool_selection = None; - } - - pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) { - self.granted_permissions = crate::sandboxing::merge_permission_profiles( - self.granted_permissions.as_ref(), - Some(&permissions), - ); - } - - pub(crate) fn granted_permissions(&self) -> Option { - self.granted_permissions.clone() - } - // Adds connector IDs to the active set and returns the merged selection. pub(crate) fn merge_connector_selection(&mut self, connector_ids: I) -> HashSet where @@ -265,6 +206,15 @@ impl SessionState { ) -> Option { self.pending_session_start_source.take() } + + pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) { + self.granted_permissions = + merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); + } + + pub(crate) fn granted_permissions(&self) -> Option { + self.granted_permissions.clone() + } } // Sometimes new snapshots don't include credits or plan information. @@ -287,263 +237,5 @@ fn merge_rate_limit_fields( } #[cfg(test)] -mod tests { - use super::*; - use crate::codex::make_session_configuration_for_tests; - use crate::protocol::RateLimitWindow; - use pretty_assertions::assert_eq; - - #[tokio::test] - async fn merge_mcp_tool_selection_deduplicates_and_preserves_order() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - - let merged = state.merge_mcp_tool_selection(vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - "mcp__rmcp__echo".to_string(), - ]); - assert_eq!( - merged, - vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - ] - ); - - let merged = state.merge_mcp_tool_selection(vec![ - "mcp__rmcp__image".to_string(), - "mcp__rmcp__search".to_string(), - ]); - assert_eq!( - merged, - vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - "mcp__rmcp__search".to_string(), - ] - ); - } - - #[tokio::test] - async fn merge_mcp_tool_selection_empty_input_is_noop() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - state.merge_mcp_tool_selection(vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - ]); - - let merged = state.merge_mcp_tool_selection(Vec::new()); - assert_eq!( - merged, - vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - ] - ); - assert_eq!( - state.get_mcp_tool_selection(), - Some(vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - ]) - ); - } - - #[tokio::test] - async fn clear_mcp_tool_selection_removes_selection() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - state.merge_mcp_tool_selection(vec!["mcp__rmcp__echo".to_string()]); - - state.clear_mcp_tool_selection(); - - assert_eq!(state.get_mcp_tool_selection(), None); - } - - #[tokio::test] - async fn set_mcp_tool_selection_deduplicates_and_preserves_order() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - state.merge_mcp_tool_selection(vec!["mcp__rmcp__old".to_string()]); - - state.set_mcp_tool_selection(vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__search".to_string(), - ]); - - assert_eq!( - state.get_mcp_tool_selection(), - Some(vec![ - "mcp__rmcp__echo".to_string(), - "mcp__rmcp__image".to_string(), - "mcp__rmcp__search".to_string(), - ]) - ); - } - - #[tokio::test] - async fn set_mcp_tool_selection_empty_input_clears_selection() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - state.merge_mcp_tool_selection(vec!["mcp__rmcp__echo".to_string()]); - - state.set_mcp_tool_selection(Vec::new()); - - assert_eq!(state.get_mcp_tool_selection(), None); - } - - #[tokio::test] - // Verifies connector merging deduplicates repeated IDs. - async fn merge_connector_selection_deduplicates_entries() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - let merged = state.merge_connector_selection([ - "calendar".to_string(), - "calendar".to_string(), - "drive".to_string(), - ]); - - assert_eq!( - merged, - HashSet::from(["calendar".to_string(), "drive".to_string()]) - ); - } - - #[tokio::test] - // Verifies clearing connector selection removes all saved IDs. - async fn clear_connector_selection_removes_entries() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - state.merge_connector_selection(["calendar".to_string()]); - - state.clear_connector_selection(); - - assert_eq!(state.get_connector_selection(), HashSet::new()); - } - - #[tokio::test] - async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - - state.set_rate_limits(RateLimitSnapshot { - limit_id: None, - limit_name: None, - primary: Some(RateLimitWindow { - used_percent: 12.0, - window_minutes: Some(60), - resets_at: Some(100), - }), - secondary: None, - credits: None, - plan_type: None, - }); - - assert_eq!( - state - .latest_rate_limits - .as_ref() - .and_then(|v| v.limit_id.clone()), - Some("codex".to_string()) - ); - } - - #[tokio::test] - async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_bucket() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - - state.set_rate_limits(RateLimitSnapshot { - limit_id: Some("codex_other".to_string()), - limit_name: Some("codex_other".to_string()), - primary: Some(RateLimitWindow { - used_percent: 20.0, - window_minutes: Some(60), - resets_at: Some(200), - }), - secondary: None, - credits: None, - plan_type: None, - }); - state.set_rate_limits(RateLimitSnapshot { - limit_id: None, - limit_name: None, - primary: Some(RateLimitWindow { - used_percent: 30.0, - window_minutes: Some(60), - resets_at: Some(300), - }), - secondary: None, - credits: None, - plan_type: None, - }); - - assert_eq!( - state - .latest_rate_limits - .as_ref() - .and_then(|v| v.limit_id.clone()), - Some("codex".to_string()) - ); - } - - #[tokio::test] - async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other() { - let session_configuration = make_session_configuration_for_tests().await; - let mut state = SessionState::new(session_configuration); - - state.set_rate_limits(RateLimitSnapshot { - limit_id: Some("codex".to_string()), - limit_name: Some("codex".to_string()), - primary: Some(RateLimitWindow { - used_percent: 10.0, - window_minutes: Some(60), - resets_at: Some(100), - }), - secondary: None, - credits: Some(crate::protocol::CreditsSnapshot { - has_credits: true, - unlimited: false, - balance: Some("50".to_string()), - }), - plan_type: Some(codex_protocol::account::PlanType::Plus), - }); - - state.set_rate_limits(RateLimitSnapshot { - limit_id: Some("codex_other".to_string()), - limit_name: None, - primary: Some(RateLimitWindow { - used_percent: 30.0, - window_minutes: Some(120), - resets_at: Some(200), - }), - secondary: None, - credits: None, - plan_type: None, - }); - - assert_eq!( - state.latest_rate_limits, - Some(RateLimitSnapshot { - limit_id: Some("codex_other".to_string()), - limit_name: None, - primary: Some(RateLimitWindow { - used_percent: 30.0, - window_minutes: Some(120), - resets_at: Some(200), - }), - secondary: None, - credits: Some(crate::protocol::CreditsSnapshot { - has_credits: true, - unlimited: false, - balance: Some("50".to_string()), - }), - plan_type: Some(codex_protocol::account::PlanType::Plus), - }) - ); - } -} +#[path = "session_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs new file mode 100644 index 0000000000..2b7c276d7e --- /dev/null +++ b/codex-rs/core/src/state/session_tests.rs @@ -0,0 +1,155 @@ +use super::*; +use crate::codex::make_session_configuration_for_tests; +use crate::protocol::RateLimitWindow; +use pretty_assertions::assert_eq; + +#[tokio::test] +// Verifies connector merging deduplicates repeated IDs. +async fn merge_connector_selection_deduplicates_entries() { + let session_configuration = make_session_configuration_for_tests().await; + let mut state = SessionState::new(session_configuration); + let merged = state.merge_connector_selection([ + "calendar".to_string(), + "calendar".to_string(), + "drive".to_string(), + ]); + + assert_eq!( + merged, + HashSet::from(["calendar".to_string(), "drive".to_string()]) + ); +} + +#[tokio::test] +// Verifies clearing connector selection removes all saved IDs. +async fn clear_connector_selection_removes_entries() { + let session_configuration = make_session_configuration_for_tests().await; + let mut state = SessionState::new(session_configuration); + state.merge_connector_selection(["calendar".to_string()]); + + state.clear_connector_selection(); + + assert_eq!(state.get_connector_selection(), HashSet::new()); +} + +#[tokio::test] +async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() { + let session_configuration = make_session_configuration_for_tests().await; + let mut state = SessionState::new(session_configuration); + + state.set_rate_limits(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 12.0, + window_minutes: Some(60), + resets_at: Some(100), + }), + secondary: None, + credits: None, + plan_type: None, + }); + + assert_eq!( + state + .latest_rate_limits + .as_ref() + .and_then(|v| v.limit_id.clone()), + Some("codex".to_string()) + ); +} + +#[tokio::test] +async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_bucket() { + let session_configuration = make_session_configuration_for_tests().await; + let mut state = SessionState::new(session_configuration); + + state.set_rate_limits(RateLimitSnapshot { + limit_id: Some("codex_other".to_string()), + limit_name: Some("codex_other".to_string()), + primary: Some(RateLimitWindow { + used_percent: 20.0, + window_minutes: Some(60), + resets_at: Some(200), + }), + secondary: None, + credits: None, + plan_type: None, + }); + state.set_rate_limits(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 30.0, + window_minutes: Some(60), + resets_at: Some(300), + }), + secondary: None, + credits: None, + plan_type: None, + }); + + assert_eq!( + state + .latest_rate_limits + .as_ref() + .and_then(|v| v.limit_id.clone()), + Some("codex".to_string()) + ); +} + +#[tokio::test] +async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other() { + let session_configuration = make_session_configuration_for_tests().await; + let mut state = SessionState::new(session_configuration); + + state.set_rate_limits(RateLimitSnapshot { + limit_id: Some("codex".to_string()), + limit_name: Some("codex".to_string()), + primary: Some(RateLimitWindow { + used_percent: 10.0, + window_minutes: Some(60), + resets_at: Some(100), + }), + secondary: None, + credits: Some(crate::protocol::CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("50".to_string()), + }), + plan_type: Some(codex_protocol::account::PlanType::Plus), + }); + + state.set_rate_limits(RateLimitSnapshot { + limit_id: Some("codex_other".to_string()), + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 30.0, + window_minutes: Some(120), + resets_at: Some(200), + }), + secondary: None, + credits: None, + plan_type: None, + }); + + assert_eq!( + state.latest_rate_limits, + Some(RateLimitSnapshot { + limit_id: Some("codex_other".to_string()), + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 30.0, + window_minutes: Some(120), + resets_at: Some(200), + }), + secondary: None, + credits: Some(crate::protocol::CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("50".to_string()), + }), + plan_type: Some(codex_protocol::account::PlanType::Plus), + }) + ); +} diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/core/src/state_db.rs index b53b748f3f..536b335035 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/core/src/state_db.rs @@ -543,26 +543,5 @@ pub async fn touch_thread_updated_at( } #[cfg(test)] -mod tests { - use super::*; - use crate::rollout::list::parse_cursor; - use pretty_assertions::assert_eq; - - #[test] - fn cursor_to_anchor_normalizes_timestamp_format() { - let uuid = Uuid::new_v4(); - let ts_str = "2026-01-27T12-34-56"; - let token = format!("{ts_str}|{uuid}"); - let cursor = parse_cursor(token.as_str()).expect("cursor should parse"); - let anchor = cursor_to_anchor(Some(&cursor)).expect("anchor should parse"); - - let naive = - NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%dT%H-%M-%S").expect("ts should parse"); - let expected_ts = DateTime::::from_naive_utc_and_offset(naive, Utc) - .with_nanosecond(0) - .expect("nanosecond"); - - assert_eq!(anchor.id, uuid); - assert_eq!(anchor.ts, expected_ts); - } -} +#[path = "state_db_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/state_db_tests.rs b/codex-rs/core/src/state_db_tests.rs new file mode 100644 index 0000000000..adf08197d6 --- /dev/null +++ b/codex-rs/core/src/state_db_tests.rs @@ -0,0 +1,21 @@ +use super::*; +use crate::rollout::list::parse_cursor; +use pretty_assertions::assert_eq; + +#[test] +fn cursor_to_anchor_normalizes_timestamp_format() { + let uuid = Uuid::new_v4(); + let ts_str = "2026-01-27T12-34-56"; + let token = format!("{ts_str}|{uuid}"); + let cursor = parse_cursor(token.as_str()).expect("cursor should parse"); + let anchor = cursor_to_anchor(Some(&cursor)).expect("anchor should parse"); + + let naive = + NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%dT%H-%M-%S").expect("ts should parse"); + let expected_ts = DateTime::::from_naive_utc_and_offset(naive, Utc) + .with_nanosecond(0) + .expect("nanosecond"); + + assert_eq!(anchor.id, uuid); + assert_eq!(anchor.ts, expected_ts); +} diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 1a758fcb6e..e5231314e5 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -335,7 +335,9 @@ pub(crate) async fn handle_non_tool_response_item( } Some(turn_item) } - ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } => { + ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } => { debug!("unexpected tool output from stream"); None } @@ -381,149 +383,21 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti output, }) } + ResponseInputItem::ToolSearchOutput { + call_id, + status, + execution, + tools, + } => Some(ResponseItem::ToolSearchOutput { + call_id: Some(call_id.clone()), + status: status.clone(), + execution: execution.clone(), + tools: tools.clone(), + }), _ => None, } } #[cfg(test)] -mod tests { - use super::default_image_generation_output_dir; - use super::handle_non_tool_response_item; - use super::last_assistant_message_from_item; - use super::save_image_generation_result; - use crate::codex::make_session_and_context; - use crate::error::CodexErr; - use codex_protocol::items::TurnItem; - use codex_protocol::models::ContentItem; - use codex_protocol::models::ResponseItem; - use pretty_assertions::assert_eq; - - fn assistant_output_text(text: &str) -> ResponseItem { - ResponseItem::Message { - id: Some("msg-1".to_string()), - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: text.to_string(), - }], - metadata: None, - end_turn: Some(true), - phase: None, - } - } - - #[tokio::test] - async fn handle_non_tool_response_item_strips_citations_from_assistant_message() { - let (session, turn_context) = make_session_and_context().await; - let item = assistant_output_text("hellodoc1 world"); - - let turn_item = handle_non_tool_response_item(&session, &turn_context, &item, false) - .await - .expect("assistant message should parse"); - - let TurnItem::AgentMessage(agent_message) = turn_item else { - panic!("expected agent message"); - }; - let text = agent_message - .content - .iter() - .map(|entry| match entry { - codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), - }) - .collect::(); - assert_eq!(text, "hello world"); - } - - #[test] - fn last_assistant_message_from_item_strips_citations_and_plan_blocks() { - let item = assistant_output_text( - "beforedoc1\n\n- x\n\nafter", - ); - - let message = last_assistant_message_from_item(&item, true) - .expect("assistant text should remain after stripping"); - - assert_eq!(message, "before\nafter"); - } - - #[test] - fn last_assistant_message_from_item_returns_none_for_citation_only_message() { - let item = assistant_output_text("doc1"); - - assert_eq!(last_assistant_message_from_item(&item, false), None); - } - - #[test] - fn last_assistant_message_from_item_returns_none_for_plan_only_hidden_message() { - let item = assistant_output_text("\n- x\n"); - - assert_eq!(last_assistant_message_from_item(&item, true), None); - } - - #[tokio::test] - async fn save_image_generation_result_saves_base64_to_png_in_temp_dir() { - let expected_path = default_image_generation_output_dir().join("ig_save_base64.png"); - let _ = std::fs::remove_file(&expected_path); - - let saved_path = save_image_generation_result("ig_save_base64", "Zm9v") - .await - .expect("image should be saved"); - - assert_eq!(saved_path, expected_path); - assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo"); - let _ = std::fs::remove_file(&saved_path); - } - - #[tokio::test] - async fn save_image_generation_result_rejects_data_url_payload() { - let result = "data:image/jpeg;base64,Zm9v"; - - let err = save_image_generation_result("ig_456", result) - .await - .expect_err("data url payload should error"); - assert!(matches!(err, CodexErr::InvalidRequest(_))); - } - - #[tokio::test] - async fn save_image_generation_result_overwrites_existing_file() { - let existing_path = default_image_generation_output_dir().join("ig_overwrite.png"); - std::fs::write(&existing_path, b"existing").expect("seed existing image"); - - let saved_path = save_image_generation_result("ig_overwrite", "Zm9v") - .await - .expect("image should be saved"); - - assert_eq!(saved_path, existing_path); - assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo"); - let _ = std::fs::remove_file(&saved_path); - } - - #[tokio::test] - async fn save_image_generation_result_sanitizes_call_id_for_temp_dir_output_path() { - let expected_path = default_image_generation_output_dir().join("___ig___.png"); - let _ = std::fs::remove_file(&expected_path); - - let saved_path = save_image_generation_result("../ig/..", "Zm9v") - .await - .expect("image should be saved"); - - assert_eq!(saved_path, expected_path); - assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo"); - let _ = std::fs::remove_file(&saved_path); - } - - #[tokio::test] - async fn save_image_generation_result_rejects_non_standard_base64() { - let err = save_image_generation_result("ig_urlsafe", "_-8") - .await - .expect_err("non-standard base64 should error"); - assert!(matches!(err, CodexErr::InvalidRequest(_))); - } - - #[tokio::test] - async fn save_image_generation_result_rejects_non_base64_data_urls() { - let err = save_image_generation_result("ig_svg", "data:image/svg+xml,") - .await - .expect_err("non-base64 data url should error"); - assert!(matches!(err, CodexErr::InvalidRequest(_))); - } -} +#[path = "stream_events_utils_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs new file mode 100644 index 0000000000..c7c919d1d9 --- /dev/null +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -0,0 +1,139 @@ +use super::default_image_generation_output_dir; +use super::handle_non_tool_response_item; +use super::last_assistant_message_from_item; +use super::save_image_generation_result; +use crate::codex::make_session_and_context; +use crate::error::CodexErr; +use codex_protocol::items::TurnItem; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; + +fn assistant_output_text(text: &str) -> ResponseItem { + ResponseItem::Message { + id: Some("msg-1".to_string()), + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + metadata: None, + end_turn: Some(true), + phase: None, + } +} + +#[tokio::test] +async fn handle_non_tool_response_item_strips_citations_from_assistant_message() { + let (session, turn_context) = make_session_and_context().await; + let item = assistant_output_text("hellodoc1 world"); + + let turn_item = handle_non_tool_response_item(&session, &turn_context, &item, false) + .await + .expect("assistant message should parse"); + + let TurnItem::AgentMessage(agent_message) = turn_item else { + panic!("expected agent message"); + }; + let text = agent_message + .content + .iter() + .map(|entry| match entry { + codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), + }) + .collect::(); + assert_eq!(text, "hello world"); +} + +#[test] +fn last_assistant_message_from_item_strips_citations_and_plan_blocks() { + let item = assistant_output_text( + "beforedoc1\n\n- x\n\nafter", + ); + + let message = last_assistant_message_from_item(&item, true) + .expect("assistant text should remain after stripping"); + + assert_eq!(message, "before\nafter"); +} + +#[test] +fn last_assistant_message_from_item_returns_none_for_citation_only_message() { + let item = assistant_output_text("doc1"); + + assert_eq!(last_assistant_message_from_item(&item, false), None); +} + +#[test] +fn last_assistant_message_from_item_returns_none_for_plan_only_hidden_message() { + let item = assistant_output_text("\n- x\n"); + + assert_eq!(last_assistant_message_from_item(&item, true), None); +} + +#[tokio::test] +async fn save_image_generation_result_saves_base64_to_png_in_temp_dir() { + let expected_path = default_image_generation_output_dir().join("ig_save_base64.png"); + let _ = std::fs::remove_file(&expected_path); + + let saved_path = save_image_generation_result("ig_save_base64", "Zm9v") + .await + .expect("image should be saved"); + + assert_eq!(saved_path, expected_path); + assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo"); + let _ = std::fs::remove_file(&saved_path); +} + +#[tokio::test] +async fn save_image_generation_result_rejects_data_url_payload() { + let result = "data:image/jpeg;base64,Zm9v"; + + let err = save_image_generation_result("ig_456", result) + .await + .expect_err("data url payload should error"); + assert!(matches!(err, CodexErr::InvalidRequest(_))); +} + +#[tokio::test] +async fn save_image_generation_result_overwrites_existing_file() { + let existing_path = default_image_generation_output_dir().join("ig_overwrite.png"); + std::fs::write(&existing_path, b"existing").expect("seed existing image"); + + let saved_path = save_image_generation_result("ig_overwrite", "Zm9v") + .await + .expect("image should be saved"); + + assert_eq!(saved_path, existing_path); + assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo"); + let _ = std::fs::remove_file(&saved_path); +} + +#[tokio::test] +async fn save_image_generation_result_sanitizes_call_id_for_temp_dir_output_path() { + let expected_path = default_image_generation_output_dir().join("___ig___.png"); + let _ = std::fs::remove_file(&expected_path); + + let saved_path = save_image_generation_result("../ig/..", "Zm9v") + .await + .expect("image should be saved"); + + assert_eq!(saved_path, expected_path); + assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo"); + let _ = std::fs::remove_file(&saved_path); +} + +#[tokio::test] +async fn save_image_generation_result_rejects_non_standard_base64() { + let err = save_image_generation_result("ig_urlsafe", "_-8") + .await + .expect_err("non-standard base64 should error"); + assert!(matches!(err, CodexErr::InvalidRequest(_))); +} + +#[tokio::test] +async fn save_image_generation_result_rejects_non_base64_data_urls() { + let err = save_image_generation_result("ig_svg", "data:image/svg+xml,") + .await + .expect_err("non-base64 data url should error"); + assert!(matches!(err, CodexErr::InvalidRequest(_))); +} diff --git a/codex-rs/core/src/tasks/ghost_snapshot.rs b/codex-rs/core/src/tasks/ghost_snapshot.rs index ded8533a23..01aa9758f7 100644 --- a/codex-rs/core/src/tasks/ghost_snapshot.rs +++ b/codex-rs/core/src/tasks/ghost_snapshot.rs @@ -250,36 +250,5 @@ fn format_bytes(bytes: i64) -> String { } #[cfg(test)] -mod tests { - use super::*; - use codex_git::LargeUntrackedDir; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - - #[test] - fn large_untracked_warning_includes_threshold() { - let report = GhostSnapshotReport { - large_untracked_dirs: vec![LargeUntrackedDir { - path: PathBuf::from("models"), - file_count: 250, - }], - ignored_untracked_files: Vec::new(), - }; - - let message = format_large_untracked_warning(Some(200), &report).unwrap(); - assert!(message.contains(">= 200 files")); - } - - #[test] - fn large_untracked_warning_disabled_when_threshold_disabled() { - let report = GhostSnapshotReport { - large_untracked_dirs: vec![LargeUntrackedDir { - path: PathBuf::from("models"), - file_count: 250, - }], - ignored_untracked_files: Vec::new(), - }; - - assert_eq!(format_large_untracked_warning(None, &report), None); - } -} +#[path = "ghost_snapshot_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tasks/ghost_snapshot_tests.rs b/codex-rs/core/src/tasks/ghost_snapshot_tests.rs new file mode 100644 index 0000000000..1884a9bd05 --- /dev/null +++ b/codex-rs/core/src/tasks/ghost_snapshot_tests.rs @@ -0,0 +1,31 @@ +use super::*; +use codex_git::LargeUntrackedDir; +use pretty_assertions::assert_eq; +use std::path::PathBuf; + +#[test] +fn large_untracked_warning_includes_threshold() { + let report = GhostSnapshotReport { + large_untracked_dirs: vec![LargeUntrackedDir { + path: PathBuf::from("models"), + file_count: 250, + }], + ignored_untracked_files: Vec::new(), + }; + + let message = format_large_untracked_warning(Some(200), &report).unwrap(); + assert!(message.contains(">= 200 files")); +} + +#[test] +fn large_untracked_warning_disabled_when_threshold_disabled() { + let report = GhostSnapshotReport { + large_untracked_dirs: vec![LargeUntrackedDir { + path: PathBuf::from("models"), + file_count: 250, + }], + ignored_untracked_files: Vec::new(), + }; + + assert_eq!(format_large_untracked_warning(None, &report), None); +} diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f098c28e74..e2bc1fc35e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -455,119 +455,5 @@ impl Session { } #[cfg(test)] -mod tests { - use super::emit_turn_network_proxy_metric; - use codex_otel::SessionTelemetry; - use codex_otel::metrics::MetricsClient; - use codex_otel::metrics::MetricsConfig; - use codex_otel::metrics::names::TURN_NETWORK_PROXY_METRIC; - use codex_protocol::ThreadId; - use codex_protocol::protocol::SessionSource; - use opentelemetry::KeyValue; - use opentelemetry_sdk::metrics::InMemoryMetricExporter; - use opentelemetry_sdk::metrics::data::AggregatedMetrics; - use opentelemetry_sdk::metrics::data::Metric; - use opentelemetry_sdk::metrics::data::MetricData; - use opentelemetry_sdk::metrics::data::ResourceMetrics; - use pretty_assertions::assert_eq; - use std::collections::BTreeMap; - - fn test_session_telemetry() -> SessionTelemetry { - let exporter = InMemoryMetricExporter::default(); - let metrics = MetricsClient::new( - MetricsConfig::in_memory("test", "codex-core", env!("CARGO_PKG_VERSION"), exporter) - .with_runtime_reader(), - ) - .expect("in-memory metrics client"); - SessionTelemetry::new( - ThreadId::new(), - "gpt-5.1", - "gpt-5.1", - None, - None, - None, - "test_originator".to_string(), - false, - "tty".to_string(), - SessionSource::Cli, - ) - .with_metrics_without_metadata_tags(metrics) - } - - fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric { - for scope_metrics in resource_metrics.scope_metrics() { - for metric in scope_metrics.metrics() { - if metric.name() == name { - return metric; - } - } - } - panic!("metric {name} missing"); - } - - fn attributes_to_map<'a>( - attributes: impl Iterator, - ) -> BTreeMap { - attributes - .map(|kv| (kv.key.as_str().to_string(), kv.value.as_str().to_string())) - .collect() - } - - fn metric_point(resource_metrics: &ResourceMetrics) -> (BTreeMap, u64) { - let metric = find_metric(resource_metrics, TURN_NETWORK_PROXY_METRIC); - match metric.data() { - AggregatedMetrics::U64(data) => match data { - MetricData::Sum(sum) => { - let points: Vec<_> = sum.data_points().collect(); - assert_eq!(points.len(), 1); - let point = points[0]; - (attributes_to_map(point.attributes()), point.value()) - } - _ => panic!("unexpected counter aggregation"), - }, - _ => panic!("unexpected counter data type"), - } - } - - #[test] - fn emit_turn_network_proxy_metric_records_active_turn() { - let session_telemetry = test_session_telemetry(); - - emit_turn_network_proxy_metric(&session_telemetry, true, ("tmp_mem_enabled", "true")); - - let snapshot = session_telemetry - .snapshot_metrics() - .expect("runtime metrics snapshot"); - let (attrs, value) = metric_point(&snapshot); - - assert_eq!(value, 1); - assert_eq!( - attrs, - BTreeMap::from([ - ("active".to_string(), "true".to_string()), - ("tmp_mem_enabled".to_string(), "true".to_string()), - ]) - ); - } - - #[test] - fn emit_turn_network_proxy_metric_records_inactive_turn() { - let session_telemetry = test_session_telemetry(); - - emit_turn_network_proxy_metric(&session_telemetry, false, ("tmp_mem_enabled", "false")); - - let snapshot = session_telemetry - .snapshot_metrics() - .expect("runtime metrics snapshot"); - let (attrs, value) = metric_point(&snapshot); - - assert_eq!(value, 1); - assert_eq!( - attrs, - BTreeMap::from([ - ("active".to_string(), "false".to_string()), - ("tmp_mem_enabled".to_string(), "false".to_string()), - ]) - ); - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tasks/mod_tests.rs b/codex-rs/core/src/tasks/mod_tests.rs new file mode 100644 index 0000000000..7a55d55f6f --- /dev/null +++ b/codex-rs/core/src/tasks/mod_tests.rs @@ -0,0 +1,114 @@ +use super::emit_turn_network_proxy_metric; +use codex_otel::SessionTelemetry; +use codex_otel::metrics::MetricsClient; +use codex_otel::metrics::MetricsConfig; +use codex_otel::metrics::names::TURN_NETWORK_PROXY_METRIC; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; +use opentelemetry::KeyValue; +use opentelemetry_sdk::metrics::InMemoryMetricExporter; +use opentelemetry_sdk::metrics::data::AggregatedMetrics; +use opentelemetry_sdk::metrics::data::Metric; +use opentelemetry_sdk::metrics::data::MetricData; +use opentelemetry_sdk::metrics::data::ResourceMetrics; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; + +fn test_session_telemetry() -> SessionTelemetry { + let exporter = InMemoryMetricExporter::default(); + let metrics = MetricsClient::new( + MetricsConfig::in_memory("test", "codex-core", env!("CARGO_PKG_VERSION"), exporter) + .with_runtime_reader(), + ) + .expect("in-memory metrics client"); + SessionTelemetry::new( + ThreadId::new(), + "gpt-5.1", + "gpt-5.1", + None, + None, + None, + "test_originator".to_string(), + false, + "tty".to_string(), + SessionSource::Cli, + ) + .with_metrics_without_metadata_tags(metrics) +} + +fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric { + for scope_metrics in resource_metrics.scope_metrics() { + for metric in scope_metrics.metrics() { + if metric.name() == name { + return metric; + } + } + } + panic!("metric {name} missing"); +} + +fn attributes_to_map<'a>( + attributes: impl Iterator, +) -> BTreeMap { + attributes + .map(|kv| (kv.key.as_str().to_string(), kv.value.as_str().to_string())) + .collect() +} + +fn metric_point(resource_metrics: &ResourceMetrics) -> (BTreeMap, u64) { + let metric = find_metric(resource_metrics, TURN_NETWORK_PROXY_METRIC); + match metric.data() { + AggregatedMetrics::U64(data) => match data { + MetricData::Sum(sum) => { + let points: Vec<_> = sum.data_points().collect(); + assert_eq!(points.len(), 1); + let point = points[0]; + (attributes_to_map(point.attributes()), point.value()) + } + _ => panic!("unexpected counter aggregation"), + }, + _ => panic!("unexpected counter data type"), + } +} + +#[test] +fn emit_turn_network_proxy_metric_records_active_turn() { + let session_telemetry = test_session_telemetry(); + + emit_turn_network_proxy_metric(&session_telemetry, true, ("tmp_mem_enabled", "true")); + + let snapshot = session_telemetry + .snapshot_metrics() + .expect("runtime metrics snapshot"); + let (attrs, value) = metric_point(&snapshot); + + assert_eq!(value, 1); + assert_eq!( + attrs, + BTreeMap::from([ + ("active".to_string(), "true".to_string()), + ("tmp_mem_enabled".to_string(), "true".to_string()), + ]) + ); +} + +#[test] +fn emit_turn_network_proxy_metric_records_inactive_turn() { + let session_telemetry = test_session_telemetry(); + + emit_turn_network_proxy_metric(&session_telemetry, false, ("tmp_mem_enabled", "false")); + + let snapshot = session_telemetry + .snapshot_metrics() + .expect("runtime metrics snapshot"); + let (attrs, value) = metric_point(&snapshot); + + assert_eq!(value, 1); + assert_eq!( + attrs, + BTreeMap::from([ + ("active".to_string(), "false".to_string()), + ("tmp_mem_enabled".to_string(), "false".to_string()), + ]) + ); +} diff --git a/codex-rs/core/src/terminal.rs b/codex-rs/core/src/terminal.rs index b91aef106b..f875fcd9e6 100644 --- a/codex-rs/core/src/terminal.rs +++ b/codex-rs/core/src/terminal.rs @@ -461,707 +461,5 @@ fn none_if_whitespace(value: String) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::collections::HashMap; - - struct FakeEnvironment { - vars: HashMap, - tmux_client_info: TmuxClientInfo, - } - - impl FakeEnvironment { - fn new() -> Self { - Self { - vars: HashMap::new(), - tmux_client_info: TmuxClientInfo::default(), - } - } - - fn with_var(mut self, key: &str, value: &str) -> Self { - self.vars.insert(key.to_string(), value.to_string()); - self - } - - fn with_tmux_client_info(mut self, termtype: Option<&str>, termname: Option<&str>) -> Self { - self.tmux_client_info = TmuxClientInfo { - termtype: termtype.map(ToString::to_string), - termname: termname.map(ToString::to_string), - }; - self - } - } - - impl Environment for FakeEnvironment { - fn var(&self, name: &str) -> Option { - self.vars.get(name).cloned() - } - - fn tmux_client_info(&self) -> TmuxClientInfo { - self.tmux_client_info.clone() - } - } - - fn terminal_info( - name: TerminalName, - term_program: Option<&str>, - version: Option<&str>, - term: Option<&str>, - multiplexer: Option, - ) -> TerminalInfo { - TerminalInfo { - name, - term_program: term_program.map(ToString::to_string), - version: version.map(ToString::to_string), - term: term.map(ToString::to_string), - multiplexer, - } - } - - #[test] - fn detects_term_program() { - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "iTerm.app") - .with_var("TERM_PROGRAM_VERSION", "3.5.0") - .with_var("WEZTERM_VERSION", "2024.2"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Iterm2, - Some("iTerm.app"), - Some("3.5.0"), - None, - None, - ), - "term_program_with_version_info" - ); - assert_eq!( - terminal.user_agent_token(), - "iTerm.app/3.5.0", - "term_program_with_version_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "iTerm.app") - .with_var("TERM_PROGRAM_VERSION", ""); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Iterm2, Some("iTerm.app"), None, None, None), - "term_program_without_version_info" - ); - assert_eq!( - terminal.user_agent_token(), - "iTerm.app", - "term_program_without_version_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "iTerm.app") - .with_var("WEZTERM_VERSION", "2024.2"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Iterm2, Some("iTerm.app"), None, None, None), - "term_program_overrides_wezterm_info" - ); - assert_eq!( - terminal.user_agent_token(), - "iTerm.app", - "term_program_overrides_wezterm_user_agent" - ); - } - - #[test] - fn detects_iterm2() { - let env = FakeEnvironment::new().with_var("ITERM_SESSION_ID", "w0t1p0"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Iterm2, None, None, None, None), - "iterm_session_id_info" - ); - assert_eq!( - terminal.user_agent_token(), - "iTerm.app", - "iterm_session_id_user_agent" - ); - } - - #[test] - fn detects_apple_terminal() { - let env = FakeEnvironment::new().with_var("TERM_PROGRAM", "Apple_Terminal"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::AppleTerminal, - Some("Apple_Terminal"), - None, - None, - None, - ), - "apple_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Apple_Terminal", - "apple_term_program_user_agent" - ); - - let env = FakeEnvironment::new().with_var("TERM_SESSION_ID", "A1B2C3"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::AppleTerminal, None, None, None, None), - "apple_term_session_id_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Apple_Terminal", - "apple_term_session_id_user_agent" - ); - } - - #[test] - fn detects_ghostty() { - let env = FakeEnvironment::new().with_var("TERM_PROGRAM", "Ghostty"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Ghostty, Some("Ghostty"), None, None, None), - "ghostty_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Ghostty", - "ghostty_term_program_user_agent" - ); - } - - #[test] - fn detects_vscode() { - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "vscode") - .with_var("TERM_PROGRAM_VERSION", "1.86.0"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::VsCode, - Some("vscode"), - Some("1.86.0"), - None, - None - ), - "vscode_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "vscode/1.86.0", - "vscode_term_program_user_agent" - ); - } - - #[test] - fn detects_warp_terminal() { - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "WarpTerminal") - .with_var("TERM_PROGRAM_VERSION", "v0.2025.12.10.08.12.stable_03"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::WarpTerminal, - Some("WarpTerminal"), - Some("v0.2025.12.10.08.12.stable_03"), - None, - None, - ), - "warp_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WarpTerminal/v0.2025.12.10.08.12.stable_03", - "warp_term_program_user_agent" - ); - } - - #[test] - fn detects_tmux_multiplexer() { - let env = FakeEnvironment::new() - .with_var("TMUX", "/tmp/tmux-1000/default,123,0") - .with_var("TERM_PROGRAM", "tmux") - .with_tmux_client_info(Some("xterm-256color"), Some("screen-256color")); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Unknown, - Some("xterm-256color"), - None, - Some("screen-256color"), - Some(Multiplexer::Tmux { version: None }), - ), - "tmux_multiplexer_info" - ); - assert_eq!( - terminal.user_agent_token(), - "xterm-256color", - "tmux_multiplexer_user_agent" - ); - } - - #[test] - fn detects_zellij_multiplexer() { - let env = FakeEnvironment::new().with_var("ZELLIJ", "1"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - TerminalInfo { - name: TerminalName::Unknown, - term_program: None, - version: None, - term: None, - multiplexer: Some(Multiplexer::Zellij {}), - }, - "zellij_multiplexer" - ); - } - - #[test] - fn detects_tmux_client_termtype() { - let env = FakeEnvironment::new() - .with_var("TMUX", "/tmp/tmux-1000/default,123,0") - .with_var("TERM_PROGRAM", "tmux") - .with_tmux_client_info(Some("WezTerm"), None); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::WezTerm, - Some("WezTerm"), - None, - None, - Some(Multiplexer::Tmux { version: None }), - ), - "tmux_client_termtype_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WezTerm", - "tmux_client_termtype_user_agent" - ); - } - - #[test] - fn detects_tmux_client_termname() { - let env = FakeEnvironment::new() - .with_var("TMUX", "/tmp/tmux-1000/default,123,0") - .with_var("TERM_PROGRAM", "tmux") - .with_tmux_client_info(None, Some("xterm-256color")); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Unknown, - None, - None, - Some("xterm-256color"), - Some(Multiplexer::Tmux { version: None }) - ), - "tmux_client_termname_info" - ); - assert_eq!( - terminal.user_agent_token(), - "xterm-256color", - "tmux_client_termname_user_agent" - ); - } - - #[test] - fn detects_tmux_term_program_uses_client_termtype() { - let env = FakeEnvironment::new() - .with_var("TMUX", "/tmp/tmux-1000/default,123,0") - .with_var("TERM_PROGRAM", "tmux") - .with_var("TERM_PROGRAM_VERSION", "3.6a") - .with_tmux_client_info(Some("ghostty 1.2.3"), Some("xterm-ghostty")); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Ghostty, - Some("ghostty"), - Some("1.2.3"), - Some("xterm-ghostty"), - Some(Multiplexer::Tmux { - version: Some("3.6a".to_string()), - }), - ), - "tmux_term_program_client_termtype_info" - ); - assert_eq!( - terminal.user_agent_token(), - "ghostty/1.2.3", - "tmux_term_program_client_termtype_user_agent" - ); - } - - #[test] - fn detects_wezterm() { - let env = FakeEnvironment::new().with_var("WEZTERM_VERSION", "2024.2"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::WezTerm, None, Some("2024.2"), None, None), - "wezterm_version_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WezTerm/2024.2", - "wezterm_version_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "WezTerm") - .with_var("TERM_PROGRAM_VERSION", "2024.2"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::WezTerm, - Some("WezTerm"), - Some("2024.2"), - None, - None - ), - "wezterm_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WezTerm/2024.2", - "wezterm_term_program_user_agent" - ); - - let env = FakeEnvironment::new().with_var("WEZTERM_VERSION", ""); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::WezTerm, None, None, None, None), - "wezterm_empty_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WezTerm", - "wezterm_empty_user_agent" - ); - } - - #[test] - fn detects_kitty() { - let env = FakeEnvironment::new().with_var("KITTY_WINDOW_ID", "1"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Kitty, None, None, None, None), - "kitty_window_id_info" - ); - assert_eq!( - terminal.user_agent_token(), - "kitty", - "kitty_window_id_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "kitty") - .with_var("TERM_PROGRAM_VERSION", "0.30.1"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Kitty, - Some("kitty"), - Some("0.30.1"), - None, - None - ), - "kitty_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "kitty/0.30.1", - "kitty_term_program_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM", "xterm-kitty") - .with_var("ALACRITTY_SOCKET", "/tmp/alacritty"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Kitty, None, None, None, None), - "kitty_term_over_alacritty_info" - ); - assert_eq!( - terminal.user_agent_token(), - "kitty", - "kitty_term_over_alacritty_user_agent" - ); - } - - #[test] - fn detects_alacritty() { - let env = FakeEnvironment::new().with_var("ALACRITTY_SOCKET", "/tmp/alacritty"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Alacritty, None, None, None, None), - "alacritty_socket_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Alacritty", - "alacritty_socket_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "Alacritty") - .with_var("TERM_PROGRAM_VERSION", "0.13.2"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Alacritty, - Some("Alacritty"), - Some("0.13.2"), - None, - None, - ), - "alacritty_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Alacritty/0.13.2", - "alacritty_term_program_user_agent" - ); - - let env = FakeEnvironment::new().with_var("TERM", "alacritty"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Alacritty, None, None, None, None), - "alacritty_term_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Alacritty", - "alacritty_term_user_agent" - ); - } - - #[test] - fn detects_konsole() { - let env = FakeEnvironment::new().with_var("KONSOLE_VERSION", "230800"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Konsole, None, Some("230800"), None, None), - "konsole_version_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Konsole/230800", - "konsole_version_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "Konsole") - .with_var("TERM_PROGRAM_VERSION", "230800"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Konsole, - Some("Konsole"), - Some("230800"), - None, - None - ), - "konsole_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Konsole/230800", - "konsole_term_program_user_agent" - ); - - let env = FakeEnvironment::new().with_var("KONSOLE_VERSION", ""); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Konsole, None, None, None, None), - "konsole_empty_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Konsole", - "konsole_empty_user_agent" - ); - } - - #[test] - fn detects_gnome_terminal() { - let env = FakeEnvironment::new().with_var("GNOME_TERMINAL_SCREEN", "1"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::GnomeTerminal, None, None, None, None), - "gnome_terminal_screen_info" - ); - assert_eq!( - terminal.user_agent_token(), - "gnome-terminal", - "gnome_terminal_screen_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "gnome-terminal") - .with_var("TERM_PROGRAM_VERSION", "3.50"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::GnomeTerminal, - Some("gnome-terminal"), - Some("3.50"), - None, - None, - ), - "gnome_terminal_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "gnome-terminal/3.50", - "gnome_terminal_term_program_user_agent" - ); - } - - #[test] - fn detects_vte() { - let env = FakeEnvironment::new().with_var("VTE_VERSION", "7000"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Vte, None, Some("7000"), None, None), - "vte_version_info" - ); - assert_eq!( - terminal.user_agent_token(), - "VTE/7000", - "vte_version_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "VTE") - .with_var("TERM_PROGRAM_VERSION", "7000"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Vte, Some("VTE"), Some("7000"), None, None), - "vte_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "VTE/7000", - "vte_term_program_user_agent" - ); - - let env = FakeEnvironment::new().with_var("VTE_VERSION", ""); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Vte, None, None, None, None), - "vte_empty_info" - ); - assert_eq!(terminal.user_agent_token(), "VTE", "vte_empty_user_agent"); - } - - #[test] - fn detects_windows_terminal() { - let env = FakeEnvironment::new().with_var("WT_SESSION", "1"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::WindowsTerminal, None, None, None, None), - "wt_session_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WindowsTerminal", - "wt_session_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "WindowsTerminal") - .with_var("TERM_PROGRAM_VERSION", "1.21"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::WindowsTerminal, - Some("WindowsTerminal"), - Some("1.21"), - None, - None, - ), - "windows_terminal_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WindowsTerminal/1.21", - "windows_terminal_term_program_user_agent" - ); - } - - #[test] - fn detects_term_fallbacks() { - let env = FakeEnvironment::new().with_var("TERM", "xterm-256color"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Unknown, - None, - None, - Some("xterm-256color"), - None, - ), - "term_fallback_info" - ); - assert_eq!( - terminal.user_agent_token(), - "xterm-256color", - "term_fallback_user_agent" - ); - - let env = FakeEnvironment::new().with_var("TERM", "dumb"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Dumb, None, None, Some("dumb"), None), - "dumb_term_info" - ); - assert_eq!(terminal.user_agent_token(), "dumb", "dumb_term_user_agent"); - - let env = FakeEnvironment::new(); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Unknown, None, None, None, None), - "unknown_info" - ); - assert_eq!(terminal.user_agent_token(), "unknown", "unknown_user_agent"); - } -} +#[path = "terminal_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/terminal_tests.rs b/codex-rs/core/src/terminal_tests.rs new file mode 100644 index 0000000000..d779a54ab2 --- /dev/null +++ b/codex-rs/core/src/terminal_tests.rs @@ -0,0 +1,702 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::collections::HashMap; + +struct FakeEnvironment { + vars: HashMap, + tmux_client_info: TmuxClientInfo, +} + +impl FakeEnvironment { + fn new() -> Self { + Self { + vars: HashMap::new(), + tmux_client_info: TmuxClientInfo::default(), + } + } + + fn with_var(mut self, key: &str, value: &str) -> Self { + self.vars.insert(key.to_string(), value.to_string()); + self + } + + fn with_tmux_client_info(mut self, termtype: Option<&str>, termname: Option<&str>) -> Self { + self.tmux_client_info = TmuxClientInfo { + termtype: termtype.map(ToString::to_string), + termname: termname.map(ToString::to_string), + }; + self + } +} + +impl Environment for FakeEnvironment { + fn var(&self, name: &str) -> Option { + self.vars.get(name).cloned() + } + + fn tmux_client_info(&self) -> TmuxClientInfo { + self.tmux_client_info.clone() + } +} + +fn terminal_info( + name: TerminalName, + term_program: Option<&str>, + version: Option<&str>, + term: Option<&str>, + multiplexer: Option, +) -> TerminalInfo { + TerminalInfo { + name, + term_program: term_program.map(ToString::to_string), + version: version.map(ToString::to_string), + term: term.map(ToString::to_string), + multiplexer, + } +} + +#[test] +fn detects_term_program() { + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "iTerm.app") + .with_var("TERM_PROGRAM_VERSION", "3.5.0") + .with_var("WEZTERM_VERSION", "2024.2"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Iterm2, + Some("iTerm.app"), + Some("3.5.0"), + None, + None, + ), + "term_program_with_version_info" + ); + assert_eq!( + terminal.user_agent_token(), + "iTerm.app/3.5.0", + "term_program_with_version_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "iTerm.app") + .with_var("TERM_PROGRAM_VERSION", ""); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Iterm2, Some("iTerm.app"), None, None, None), + "term_program_without_version_info" + ); + assert_eq!( + terminal.user_agent_token(), + "iTerm.app", + "term_program_without_version_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "iTerm.app") + .with_var("WEZTERM_VERSION", "2024.2"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Iterm2, Some("iTerm.app"), None, None, None), + "term_program_overrides_wezterm_info" + ); + assert_eq!( + terminal.user_agent_token(), + "iTerm.app", + "term_program_overrides_wezterm_user_agent" + ); +} + +#[test] +fn detects_iterm2() { + let env = FakeEnvironment::new().with_var("ITERM_SESSION_ID", "w0t1p0"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Iterm2, None, None, None, None), + "iterm_session_id_info" + ); + assert_eq!( + terminal.user_agent_token(), + "iTerm.app", + "iterm_session_id_user_agent" + ); +} + +#[test] +fn detects_apple_terminal() { + let env = FakeEnvironment::new().with_var("TERM_PROGRAM", "Apple_Terminal"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::AppleTerminal, + Some("Apple_Terminal"), + None, + None, + None, + ), + "apple_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Apple_Terminal", + "apple_term_program_user_agent" + ); + + let env = FakeEnvironment::new().with_var("TERM_SESSION_ID", "A1B2C3"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::AppleTerminal, None, None, None, None), + "apple_term_session_id_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Apple_Terminal", + "apple_term_session_id_user_agent" + ); +} + +#[test] +fn detects_ghostty() { + let env = FakeEnvironment::new().with_var("TERM_PROGRAM", "Ghostty"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Ghostty, Some("Ghostty"), None, None, None), + "ghostty_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Ghostty", + "ghostty_term_program_user_agent" + ); +} + +#[test] +fn detects_vscode() { + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "vscode") + .with_var("TERM_PROGRAM_VERSION", "1.86.0"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::VsCode, + Some("vscode"), + Some("1.86.0"), + None, + None + ), + "vscode_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "vscode/1.86.0", + "vscode_term_program_user_agent" + ); +} + +#[test] +fn detects_warp_terminal() { + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "WarpTerminal") + .with_var("TERM_PROGRAM_VERSION", "v0.2025.12.10.08.12.stable_03"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::WarpTerminal, + Some("WarpTerminal"), + Some("v0.2025.12.10.08.12.stable_03"), + None, + None, + ), + "warp_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WarpTerminal/v0.2025.12.10.08.12.stable_03", + "warp_term_program_user_agent" + ); +} + +#[test] +fn detects_tmux_multiplexer() { + let env = FakeEnvironment::new() + .with_var("TMUX", "/tmp/tmux-1000/default,123,0") + .with_var("TERM_PROGRAM", "tmux") + .with_tmux_client_info(Some("xterm-256color"), Some("screen-256color")); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + Some("xterm-256color"), + None, + Some("screen-256color"), + Some(Multiplexer::Tmux { version: None }), + ), + "tmux_multiplexer_info" + ); + assert_eq!( + terminal.user_agent_token(), + "xterm-256color", + "tmux_multiplexer_user_agent" + ); +} + +#[test] +fn detects_zellij_multiplexer() { + let env = FakeEnvironment::new().with_var("ZELLIJ", "1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + TerminalInfo { + name: TerminalName::Unknown, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Zellij {}), + }, + "zellij_multiplexer" + ); +} + +#[test] +fn detects_tmux_client_termtype() { + let env = FakeEnvironment::new() + .with_var("TMUX", "/tmp/tmux-1000/default,123,0") + .with_var("TERM_PROGRAM", "tmux") + .with_tmux_client_info(Some("WezTerm"), None); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::WezTerm, + Some("WezTerm"), + None, + None, + Some(Multiplexer::Tmux { version: None }), + ), + "tmux_client_termtype_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WezTerm", + "tmux_client_termtype_user_agent" + ); +} + +#[test] +fn detects_tmux_client_termname() { + let env = FakeEnvironment::new() + .with_var("TMUX", "/tmp/tmux-1000/default,123,0") + .with_var("TERM_PROGRAM", "tmux") + .with_tmux_client_info(None, Some("xterm-256color")); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + None, + None, + Some("xterm-256color"), + Some(Multiplexer::Tmux { version: None }) + ), + "tmux_client_termname_info" + ); + assert_eq!( + terminal.user_agent_token(), + "xterm-256color", + "tmux_client_termname_user_agent" + ); +} + +#[test] +fn detects_tmux_term_program_uses_client_termtype() { + let env = FakeEnvironment::new() + .with_var("TMUX", "/tmp/tmux-1000/default,123,0") + .with_var("TERM_PROGRAM", "tmux") + .with_var("TERM_PROGRAM_VERSION", "3.6a") + .with_tmux_client_info(Some("ghostty 1.2.3"), Some("xterm-ghostty")); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Ghostty, + Some("ghostty"), + Some("1.2.3"), + Some("xterm-ghostty"), + Some(Multiplexer::Tmux { + version: Some("3.6a".to_string()), + }), + ), + "tmux_term_program_client_termtype_info" + ); + assert_eq!( + terminal.user_agent_token(), + "ghostty/1.2.3", + "tmux_term_program_client_termtype_user_agent" + ); +} + +#[test] +fn detects_wezterm() { + let env = FakeEnvironment::new().with_var("WEZTERM_VERSION", "2024.2"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::WezTerm, None, Some("2024.2"), None, None), + "wezterm_version_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WezTerm/2024.2", + "wezterm_version_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "WezTerm") + .with_var("TERM_PROGRAM_VERSION", "2024.2"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::WezTerm, + Some("WezTerm"), + Some("2024.2"), + None, + None + ), + "wezterm_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WezTerm/2024.2", + "wezterm_term_program_user_agent" + ); + + let env = FakeEnvironment::new().with_var("WEZTERM_VERSION", ""); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::WezTerm, None, None, None, None), + "wezterm_empty_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WezTerm", + "wezterm_empty_user_agent" + ); +} + +#[test] +fn detects_kitty() { + let env = FakeEnvironment::new().with_var("KITTY_WINDOW_ID", "1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Kitty, None, None, None, None), + "kitty_window_id_info" + ); + assert_eq!( + terminal.user_agent_token(), + "kitty", + "kitty_window_id_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "kitty") + .with_var("TERM_PROGRAM_VERSION", "0.30.1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Kitty, + Some("kitty"), + Some("0.30.1"), + None, + None + ), + "kitty_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "kitty/0.30.1", + "kitty_term_program_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM", "xterm-kitty") + .with_var("ALACRITTY_SOCKET", "/tmp/alacritty"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Kitty, None, None, None, None), + "kitty_term_over_alacritty_info" + ); + assert_eq!( + terminal.user_agent_token(), + "kitty", + "kitty_term_over_alacritty_user_agent" + ); +} + +#[test] +fn detects_alacritty() { + let env = FakeEnvironment::new().with_var("ALACRITTY_SOCKET", "/tmp/alacritty"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Alacritty, None, None, None, None), + "alacritty_socket_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Alacritty", + "alacritty_socket_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "Alacritty") + .with_var("TERM_PROGRAM_VERSION", "0.13.2"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Alacritty, + Some("Alacritty"), + Some("0.13.2"), + None, + None, + ), + "alacritty_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Alacritty/0.13.2", + "alacritty_term_program_user_agent" + ); + + let env = FakeEnvironment::new().with_var("TERM", "alacritty"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Alacritty, None, None, None, None), + "alacritty_term_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Alacritty", + "alacritty_term_user_agent" + ); +} + +#[test] +fn detects_konsole() { + let env = FakeEnvironment::new().with_var("KONSOLE_VERSION", "230800"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Konsole, None, Some("230800"), None, None), + "konsole_version_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Konsole/230800", + "konsole_version_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "Konsole") + .with_var("TERM_PROGRAM_VERSION", "230800"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Konsole, + Some("Konsole"), + Some("230800"), + None, + None + ), + "konsole_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Konsole/230800", + "konsole_term_program_user_agent" + ); + + let env = FakeEnvironment::new().with_var("KONSOLE_VERSION", ""); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Konsole, None, None, None, None), + "konsole_empty_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Konsole", + "konsole_empty_user_agent" + ); +} + +#[test] +fn detects_gnome_terminal() { + let env = FakeEnvironment::new().with_var("GNOME_TERMINAL_SCREEN", "1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::GnomeTerminal, None, None, None, None), + "gnome_terminal_screen_info" + ); + assert_eq!( + terminal.user_agent_token(), + "gnome-terminal", + "gnome_terminal_screen_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "gnome-terminal") + .with_var("TERM_PROGRAM_VERSION", "3.50"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::GnomeTerminal, + Some("gnome-terminal"), + Some("3.50"), + None, + None, + ), + "gnome_terminal_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "gnome-terminal/3.50", + "gnome_terminal_term_program_user_agent" + ); +} + +#[test] +fn detects_vte() { + let env = FakeEnvironment::new().with_var("VTE_VERSION", "7000"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Vte, None, Some("7000"), None, None), + "vte_version_info" + ); + assert_eq!( + terminal.user_agent_token(), + "VTE/7000", + "vte_version_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "VTE") + .with_var("TERM_PROGRAM_VERSION", "7000"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Vte, Some("VTE"), Some("7000"), None, None), + "vte_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "VTE/7000", + "vte_term_program_user_agent" + ); + + let env = FakeEnvironment::new().with_var("VTE_VERSION", ""); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Vte, None, None, None, None), + "vte_empty_info" + ); + assert_eq!(terminal.user_agent_token(), "VTE", "vte_empty_user_agent"); +} + +#[test] +fn detects_windows_terminal() { + let env = FakeEnvironment::new().with_var("WT_SESSION", "1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::WindowsTerminal, None, None, None, None), + "wt_session_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WindowsTerminal", + "wt_session_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "WindowsTerminal") + .with_var("TERM_PROGRAM_VERSION", "1.21"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::WindowsTerminal, + Some("WindowsTerminal"), + Some("1.21"), + None, + None, + ), + "windows_terminal_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WindowsTerminal/1.21", + "windows_terminal_term_program_user_agent" + ); +} + +#[test] +fn detects_term_fallbacks() { + let env = FakeEnvironment::new().with_var("TERM", "xterm-256color"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + None, + None, + Some("xterm-256color"), + None, + ), + "term_fallback_info" + ); + assert_eq!( + terminal.user_agent_token(), + "xterm-256color", + "term_fallback_user_agent" + ); + + let env = FakeEnvironment::new().with_var("TERM", "dumb"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Dumb, None, None, Some("dumb"), None), + "dumb_term_info" + ); + assert_eq!(terminal.user_agent_token(), "dumb", "dumb_term_user_agent"); + + let env = FakeEnvironment::new(); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Unknown, None, None, None, None), + "unknown_info" + ); + assert_eq!(terminal.user_agent_token(), "unknown", "unknown_user_agent"); +} diff --git a/codex-rs/core/src/text_encoding.rs b/codex-rs/core/src/text_encoding.rs index fde44c4195..b70d8af54c 100644 --- a/codex-rs/core/src/text_encoding.rs +++ b/codex-rs/core/src/text_encoding.rs @@ -117,345 +117,5 @@ fn is_windows_1252_punct(byte: u8) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use encoding_rs::BIG5; - use encoding_rs::EUC_KR; - use encoding_rs::GBK; - use encoding_rs::ISO_8859_2; - use encoding_rs::ISO_8859_3; - use encoding_rs::ISO_8859_4; - use encoding_rs::ISO_8859_5; - use encoding_rs::ISO_8859_6; - use encoding_rs::ISO_8859_7; - use encoding_rs::ISO_8859_8; - use encoding_rs::ISO_8859_10; - use encoding_rs::ISO_8859_13; - use encoding_rs::SHIFT_JIS; - use encoding_rs::WINDOWS_874; - use encoding_rs::WINDOWS_1250; - use encoding_rs::WINDOWS_1251; - use encoding_rs::WINDOWS_1253; - use encoding_rs::WINDOWS_1254; - use encoding_rs::WINDOWS_1255; - use encoding_rs::WINDOWS_1256; - use encoding_rs::WINDOWS_1257; - use encoding_rs::WINDOWS_1258; - use pretty_assertions::assert_eq; - - #[test] - fn test_utf8_passthrough() { - // Fast path: when UTF-8 is valid we should avoid copies and return as-is. - let utf8_text = "Hello, мир! 世界"; - let bytes = utf8_text.as_bytes(); - assert_eq!(bytes_to_string_smart(bytes), utf8_text); - } - - #[test] - fn test_cp1251_russian_text() { - // Cyrillic text emitted by PowerShell/WSL in CP1251 should decode cleanly. - let bytes = b"\xEF\xF0\xE8\xEC\xE5\xF0"; // "пример" encoded with Windows-1251 - assert_eq!(bytes_to_string_smart(bytes), "пример"); - } - - #[test] - fn test_cp1251_privet_word() { - // Regression: CP1251 words like "Привет" must not be mis-identified as Windows-1252. - let bytes = b"\xCF\xF0\xE8\xE2\xE5\xF2"; // "Привет" encoded with Windows-1251 - assert_eq!(bytes_to_string_smart(bytes), "Привет"); - } - - #[test] - fn test_koi8_r_privet_word() { - // KOI8-R output should decode to the original Cyrillic as well. - let bytes = b"\xF0\xD2\xC9\xD7\xC5\xD4"; // "Привет" encoded with KOI8-R - assert_eq!(bytes_to_string_smart(bytes), "Привет"); - } - - #[test] - fn test_cp866_russian_text() { - // Legacy consoles (cmd.exe) commonly emit CP866 bytes for Cyrillic content. - let bytes = b"\xAF\xE0\xA8\xAC\xA5\xE0"; // "пример" encoded with CP866 - assert_eq!(bytes_to_string_smart(bytes), "пример"); - } - - #[test] - fn test_cp866_uppercase_text() { - // Ensure the IBM866 heuristic still returns IBM866 for uppercase-only words. - let bytes = b"\x8F\x90\x88"; // "ПРИ" encoded with CP866 uppercase letters - assert_eq!(bytes_to_string_smart(bytes), "ПРИ"); - } - - #[test] - fn test_cp866_uppercase_followed_by_ascii() { - // Regression test: uppercase CP866 tokens next to ASCII text should not be treated as - // CP1252. - let bytes = b"\x8F\x90\x88 test"; // "ПРИ test" encoded with CP866 uppercase letters followed by ASCII - assert_eq!(bytes_to_string_smart(bytes), "ПРИ test"); - } - - #[test] - fn test_windows_1252_quotes() { - // Smart detection should map Windows-1252 punctuation into proper Unicode. - let bytes = b"\x93\x94test"; - assert_eq!(bytes_to_string_smart(bytes), "\u{201C}\u{201D}test"); - } - - #[test] - fn test_windows_1252_multiple_quotes() { - // Longer snippets of punctuation (e.g., “foo” – “bar”) should still flip to CP1252. - let bytes = b"\x93foo\x94 \x96 \x93bar\x94"; - assert_eq!( - bytes_to_string_smart(bytes), - "\u{201C}foo\u{201D} \u{2013} \u{201C}bar\u{201D}" - ); - } - - #[test] - fn test_windows_1252_privet_gibberish_is_preserved() { - // Windows-1252 cannot encode Cyrillic; if the input literally contains "ПÑ..." we should not "fix" it. - let bytes = "Привет".as_bytes(); - assert_eq!(bytes_to_string_smart(bytes), "Привет"); - } - - #[test] - fn test_iso8859_1_latin_text() { - // ISO-8859-1 (code page 28591) is the Latin segment used by LatArCyrHeb. - // encoding_rs unifies ISO-8859-1 with Windows-1252, so reuse that constant here. - let (encoded, _, had_errors) = WINDOWS_1252.encode("Hello"); - assert!(!had_errors, "failed to encode Latin sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Hello"); - } - - #[test] - fn test_iso8859_2_central_european_text() { - // ISO-8859-2 (code page 28592) covers additional Central European glyphs. - let (encoded, _, had_errors) = ISO_8859_2.encode("Příliš žluťoučký kůň"); - assert!(!had_errors, "failed to encode ISO-8859-2 sample"); - assert_eq!( - bytes_to_string_smart(encoded.as_ref()), - "Příliš žluťoučký kůň" - ); - } - - #[test] - fn test_iso8859_3_south_europe_text() { - // ISO-8859-3 (code page 28593) adds support for Maltese/Esperanto letters. - // chardetng rarely distinguishes ISO-8859-3 from neighboring Latin code pages, so we rely on - // an ASCII-only sample to ensure round-tripping still succeeds. - let (encoded, _, had_errors) = ISO_8859_3.encode("Esperanto and Maltese"); - assert!(!had_errors, "failed to encode ISO-8859-3 sample"); - assert_eq!( - bytes_to_string_smart(encoded.as_ref()), - "Esperanto and Maltese" - ); - } - - #[test] - fn test_iso8859_4_baltic_text() { - // ISO-8859-4 (code page 28594) targets the Baltic/Nordic repertoire. - let sample = "Šis ir rakstzīmju kodēšanas tests. Dažās valodās, kurās tiek \ - izmantotas latīņu valodas burti, lēmuma pieņemšanai mums ir nepieciešams \ - vairāk ieguldījuma."; - let (encoded, _, had_errors) = ISO_8859_4.encode(sample); - assert!(!had_errors, "failed to encode ISO-8859-4 sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), sample); - } - - #[test] - fn test_iso8859_5_cyrillic_text() { - // ISO-8859-5 (code page 28595) covers the Cyrillic portion. - let (encoded, _, had_errors) = ISO_8859_5.encode("Привет"); - assert!(!had_errors, "failed to encode Cyrillic sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Привет"); - } - - #[test] - fn test_iso8859_6_arabic_text() { - // ISO-8859-6 (code page 28596) covers the Arabic glyphs. - let (encoded, _, had_errors) = ISO_8859_6.encode("مرحبا"); - assert!(!had_errors, "failed to encode Arabic sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "مرحبا"); - } - - #[test] - fn test_iso8859_7_greek_text() { - // ISO-8859-7 (code page 28597) is used for Greek locales. - let (encoded, _, had_errors) = ISO_8859_7.encode("Καλημέρα"); - assert!(!had_errors, "failed to encode ISO-8859-7 sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Καλημέρα"); - } - - #[test] - fn test_iso8859_8_hebrew_text() { - // ISO-8859-8 (code page 28598) covers the Hebrew glyphs. - let (encoded, _, had_errors) = ISO_8859_8.encode("שלום"); - assert!(!had_errors, "failed to encode Hebrew sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "שלום"); - } - - #[test] - fn test_iso8859_9_turkish_text() { - // ISO-8859-9 (code page 28599) mirrors Latin-1 but inserts Turkish letters. - // encoding_rs exposes the equivalent Windows-1254 mapping. - let (encoded, _, had_errors) = WINDOWS_1254.encode("İstanbul"); - assert!(!had_errors, "failed to encode ISO-8859-9 sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "İstanbul"); - } - - #[test] - fn test_iso8859_10_nordic_text() { - // ISO-8859-10 (code page 28600) adds additional Nordic letters. - let sample = "Þetta er prófun fyrir Ægir og Øystein."; - let (encoded, _, had_errors) = ISO_8859_10.encode(sample); - assert!(!had_errors, "failed to encode ISO-8859-10 sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), sample); - } - - #[test] - fn test_iso8859_11_thai_text() { - // ISO-8859-11 (code page 28601) mirrors TIS-620 / Windows-874 for Thai. - let sample = "ภาษาไทยสำหรับการทดสอบ ISO-8859-11"; - // encoding_rs exposes the equivalent Windows-874 encoding, so use that constant. - let (encoded, _, had_errors) = WINDOWS_874.encode(sample); - assert!(!had_errors, "failed to encode ISO-8859-11 sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), sample); - } - - // ISO-8859-12 was never standardized, and encodings 14–16 cannot be distinguished reliably - // without the heuristics we removed (chardetng generally reports neighboring Latin pages), so - // we intentionally omit coverage for those slots until the detector can identify them. - - #[test] - fn test_iso8859_13_baltic_text() { - // ISO-8859-13 (code page 28603) is common across Baltic languages. - let (encoded, _, had_errors) = ISO_8859_13.encode("Sveiki"); - assert!(!had_errors, "failed to encode ISO-8859-13 sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Sveiki"); - } - - #[test] - fn test_windows_1250_central_european_text() { - let (encoded, _, had_errors) = WINDOWS_1250.encode("Příliš žluťoučký kůň"); - assert!(!had_errors, "failed to encode Central European sample"); - assert_eq!( - bytes_to_string_smart(encoded.as_ref()), - "Příliš žluťoučký kůň" - ); - } - - #[test] - fn test_windows_1251_encoded_text() { - let (encoded, _, had_errors) = WINDOWS_1251.encode("Привет из Windows-1251"); - assert!(!had_errors, "failed to encode Windows-1251 sample"); - assert_eq!( - bytes_to_string_smart(encoded.as_ref()), - "Привет из Windows-1251" - ); - } - - #[test] - fn test_windows_1253_greek_text() { - let (encoded, _, had_errors) = WINDOWS_1253.encode("Γειά σου"); - assert!(!had_errors, "failed to encode Greek sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Γειά σου"); - } - - #[test] - fn test_windows_1254_turkish_text() { - let (encoded, _, had_errors) = WINDOWS_1254.encode("İstanbul"); - assert!(!had_errors, "failed to encode Turkish sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "İstanbul"); - } - - #[test] - fn test_windows_1255_hebrew_text() { - let (encoded, _, had_errors) = WINDOWS_1255.encode("שלום"); - assert!(!had_errors, "failed to encode Windows-1255 Hebrew sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "שלום"); - } - - #[test] - fn test_windows_1256_arabic_text() { - let (encoded, _, had_errors) = WINDOWS_1256.encode("مرحبا"); - assert!(!had_errors, "failed to encode Windows-1256 Arabic sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "مرحبا"); - } - - #[test] - fn test_windows_1257_baltic_text() { - let (encoded, _, had_errors) = WINDOWS_1257.encode("Pērkons"); - assert!(!had_errors, "failed to encode Baltic sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Pērkons"); - } - - #[test] - fn test_windows_1258_vietnamese_text() { - let (encoded, _, had_errors) = WINDOWS_1258.encode("Xin chào"); - assert!(!had_errors, "failed to encode Vietnamese sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Xin chào"); - } - - #[test] - fn test_windows_874_thai_text() { - let (encoded, _, had_errors) = WINDOWS_874.encode("สวัสดีครับ นี่คือการทดสอบภาษาไทย"); - assert!(!had_errors, "failed to encode Thai sample"); - assert_eq!( - bytes_to_string_smart(encoded.as_ref()), - "สวัสดีครับ นี่คือการทดสอบภาษาไทย" - ); - } - - #[test] - fn test_windows_932_shift_jis_text() { - let (encoded, _, had_errors) = SHIFT_JIS.encode("こんにちは"); - assert!(!had_errors, "failed to encode Shift-JIS sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "こんにちは"); - } - - #[test] - fn test_windows_936_gbk_text() { - let (encoded, _, had_errors) = GBK.encode("你好,世界,这是一个测试"); - assert!(!had_errors, "failed to encode GBK sample"); - assert_eq!( - bytes_to_string_smart(encoded.as_ref()), - "你好,世界,这是一个测试" - ); - } - - #[test] - fn test_windows_949_korean_text() { - let (encoded, _, had_errors) = EUC_KR.encode("안녕하세요"); - assert!(!had_errors, "failed to encode Korean sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "안녕하세요"); - } - - #[test] - fn test_windows_950_big5_text() { - let (encoded, _, had_errors) = BIG5.encode("繁體"); - assert!(!had_errors, "failed to encode Big5 sample"); - assert_eq!(bytes_to_string_smart(encoded.as_ref()), "繁體"); - } - - #[test] - fn test_latin1_cafe() { - // Latin-1 bytes remain common in Western-European locales; decode them directly. - let bytes = b"caf\xE9"; // codespell:ignore caf - assert_eq!(bytes_to_string_smart(bytes), "café"); - } - - #[test] - fn test_preserves_ansi_sequences() { - // ANSI escape sequences should survive regardless of the detected encoding. - let bytes = b"\x1b[31mred\x1b[0m"; - assert_eq!(bytes_to_string_smart(bytes), "\x1b[31mred\x1b[0m"); - } - - #[test] - fn test_fallback_to_lossy() { - // Completely invalid sequences fall back to the old lossy behavior. - let invalid_bytes = [0xFF, 0xFE, 0xFD]; - let result = bytes_to_string_smart(&invalid_bytes); - assert_eq!(result, String::from_utf8_lossy(&invalid_bytes)); - } -} +#[path = "text_encoding_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/text_encoding_tests.rs b/codex-rs/core/src/text_encoding_tests.rs new file mode 100644 index 0000000000..6368f38be4 --- /dev/null +++ b/codex-rs/core/src/text_encoding_tests.rs @@ -0,0 +1,340 @@ +use super::*; +use encoding_rs::BIG5; +use encoding_rs::EUC_KR; +use encoding_rs::GBK; +use encoding_rs::ISO_8859_2; +use encoding_rs::ISO_8859_3; +use encoding_rs::ISO_8859_4; +use encoding_rs::ISO_8859_5; +use encoding_rs::ISO_8859_6; +use encoding_rs::ISO_8859_7; +use encoding_rs::ISO_8859_8; +use encoding_rs::ISO_8859_10; +use encoding_rs::ISO_8859_13; +use encoding_rs::SHIFT_JIS; +use encoding_rs::WINDOWS_874; +use encoding_rs::WINDOWS_1250; +use encoding_rs::WINDOWS_1251; +use encoding_rs::WINDOWS_1253; +use encoding_rs::WINDOWS_1254; +use encoding_rs::WINDOWS_1255; +use encoding_rs::WINDOWS_1256; +use encoding_rs::WINDOWS_1257; +use encoding_rs::WINDOWS_1258; +use pretty_assertions::assert_eq; + +#[test] +fn test_utf8_passthrough() { + // Fast path: when UTF-8 is valid we should avoid copies and return as-is. + let utf8_text = "Hello, мир! 世界"; + let bytes = utf8_text.as_bytes(); + assert_eq!(bytes_to_string_smart(bytes), utf8_text); +} + +#[test] +fn test_cp1251_russian_text() { + // Cyrillic text emitted by PowerShell/WSL in CP1251 should decode cleanly. + let bytes = b"\xEF\xF0\xE8\xEC\xE5\xF0"; // "пример" encoded with Windows-1251 + assert_eq!(bytes_to_string_smart(bytes), "пример"); +} + +#[test] +fn test_cp1251_privet_word() { + // Regression: CP1251 words like "Привет" must not be mis-identified as Windows-1252. + let bytes = b"\xCF\xF0\xE8\xE2\xE5\xF2"; // "Привет" encoded with Windows-1251 + assert_eq!(bytes_to_string_smart(bytes), "Привет"); +} + +#[test] +fn test_koi8_r_privet_word() { + // KOI8-R output should decode to the original Cyrillic as well. + let bytes = b"\xF0\xD2\xC9\xD7\xC5\xD4"; // "Привет" encoded with KOI8-R + assert_eq!(bytes_to_string_smart(bytes), "Привет"); +} + +#[test] +fn test_cp866_russian_text() { + // Legacy consoles (cmd.exe) commonly emit CP866 bytes for Cyrillic content. + let bytes = b"\xAF\xE0\xA8\xAC\xA5\xE0"; // "пример" encoded with CP866 + assert_eq!(bytes_to_string_smart(bytes), "пример"); +} + +#[test] +fn test_cp866_uppercase_text() { + // Ensure the IBM866 heuristic still returns IBM866 for uppercase-only words. + let bytes = b"\x8F\x90\x88"; // "ПРИ" encoded with CP866 uppercase letters + assert_eq!(bytes_to_string_smart(bytes), "ПРИ"); +} + +#[test] +fn test_cp866_uppercase_followed_by_ascii() { + // Regression test: uppercase CP866 tokens next to ASCII text should not be treated as + // CP1252. + let bytes = b"\x8F\x90\x88 test"; // "ПРИ test" encoded with CP866 uppercase letters followed by ASCII + assert_eq!(bytes_to_string_smart(bytes), "ПРИ test"); +} + +#[test] +fn test_windows_1252_quotes() { + // Smart detection should map Windows-1252 punctuation into proper Unicode. + let bytes = b"\x93\x94test"; + assert_eq!(bytes_to_string_smart(bytes), "\u{201C}\u{201D}test"); +} + +#[test] +fn test_windows_1252_multiple_quotes() { + // Longer snippets of punctuation (e.g., “foo” – “bar”) should still flip to CP1252. + let bytes = b"\x93foo\x94 \x96 \x93bar\x94"; + assert_eq!( + bytes_to_string_smart(bytes), + "\u{201C}foo\u{201D} \u{2013} \u{201C}bar\u{201D}" + ); +} + +#[test] +fn test_windows_1252_privet_gibberish_is_preserved() { + // Windows-1252 cannot encode Cyrillic; if the input literally contains "ПÑ..." we should not "fix" it. + let bytes = "Привет".as_bytes(); + assert_eq!(bytes_to_string_smart(bytes), "Привет"); +} + +#[test] +fn test_iso8859_1_latin_text() { + // ISO-8859-1 (code page 28591) is the Latin segment used by LatArCyrHeb. + // encoding_rs unifies ISO-8859-1 with Windows-1252, so reuse that constant here. + let (encoded, _, had_errors) = WINDOWS_1252.encode("Hello"); + assert!(!had_errors, "failed to encode Latin sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Hello"); +} + +#[test] +fn test_iso8859_2_central_european_text() { + // ISO-8859-2 (code page 28592) covers additional Central European glyphs. + let (encoded, _, had_errors) = ISO_8859_2.encode("Příliš žluťoučký kůň"); + assert!(!had_errors, "failed to encode ISO-8859-2 sample"); + assert_eq!( + bytes_to_string_smart(encoded.as_ref()), + "Příliš žluťoučký kůň" + ); +} + +#[test] +fn test_iso8859_3_south_europe_text() { + // ISO-8859-3 (code page 28593) adds support for Maltese/Esperanto letters. + // chardetng rarely distinguishes ISO-8859-3 from neighboring Latin code pages, so we rely on + // an ASCII-only sample to ensure round-tripping still succeeds. + let (encoded, _, had_errors) = ISO_8859_3.encode("Esperanto and Maltese"); + assert!(!had_errors, "failed to encode ISO-8859-3 sample"); + assert_eq!( + bytes_to_string_smart(encoded.as_ref()), + "Esperanto and Maltese" + ); +} + +#[test] +fn test_iso8859_4_baltic_text() { + // ISO-8859-4 (code page 28594) targets the Baltic/Nordic repertoire. + let sample = "Šis ir rakstzīmju kodēšanas tests. Dažās valodās, kurās tiek \ + izmantotas latīņu valodas burti, lēmuma pieņemšanai mums ir nepieciešams \ + vairāk ieguldījuma."; + let (encoded, _, had_errors) = ISO_8859_4.encode(sample); + assert!(!had_errors, "failed to encode ISO-8859-4 sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), sample); +} + +#[test] +fn test_iso8859_5_cyrillic_text() { + // ISO-8859-5 (code page 28595) covers the Cyrillic portion. + let (encoded, _, had_errors) = ISO_8859_5.encode("Привет"); + assert!(!had_errors, "failed to encode Cyrillic sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Привет"); +} + +#[test] +fn test_iso8859_6_arabic_text() { + // ISO-8859-6 (code page 28596) covers the Arabic glyphs. + let (encoded, _, had_errors) = ISO_8859_6.encode("مرحبا"); + assert!(!had_errors, "failed to encode Arabic sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "مرحبا"); +} + +#[test] +fn test_iso8859_7_greek_text() { + // ISO-8859-7 (code page 28597) is used for Greek locales. + let (encoded, _, had_errors) = ISO_8859_7.encode("Καλημέρα"); + assert!(!had_errors, "failed to encode ISO-8859-7 sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Καλημέρα"); +} + +#[test] +fn test_iso8859_8_hebrew_text() { + // ISO-8859-8 (code page 28598) covers the Hebrew glyphs. + let (encoded, _, had_errors) = ISO_8859_8.encode("שלום"); + assert!(!had_errors, "failed to encode Hebrew sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "שלום"); +} + +#[test] +fn test_iso8859_9_turkish_text() { + // ISO-8859-9 (code page 28599) mirrors Latin-1 but inserts Turkish letters. + // encoding_rs exposes the equivalent Windows-1254 mapping. + let (encoded, _, had_errors) = WINDOWS_1254.encode("İstanbul"); + assert!(!had_errors, "failed to encode ISO-8859-9 sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "İstanbul"); +} + +#[test] +fn test_iso8859_10_nordic_text() { + // ISO-8859-10 (code page 28600) adds additional Nordic letters. + let sample = "Þetta er prófun fyrir Ægir og Øystein."; + let (encoded, _, had_errors) = ISO_8859_10.encode(sample); + assert!(!had_errors, "failed to encode ISO-8859-10 sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), sample); +} + +#[test] +fn test_iso8859_11_thai_text() { + // ISO-8859-11 (code page 28601) mirrors TIS-620 / Windows-874 for Thai. + let sample = "ภาษาไทยสำหรับการทดสอบ ISO-8859-11"; + // encoding_rs exposes the equivalent Windows-874 encoding, so use that constant. + let (encoded, _, had_errors) = WINDOWS_874.encode(sample); + assert!(!had_errors, "failed to encode ISO-8859-11 sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), sample); +} + +// ISO-8859-12 was never standardized, and encodings 14–16 cannot be distinguished reliably +// without the heuristics we removed (chardetng generally reports neighboring Latin pages), so +// we intentionally omit coverage for those slots until the detector can identify them. + +#[test] +fn test_iso8859_13_baltic_text() { + // ISO-8859-13 (code page 28603) is common across Baltic languages. + let (encoded, _, had_errors) = ISO_8859_13.encode("Sveiki"); + assert!(!had_errors, "failed to encode ISO-8859-13 sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Sveiki"); +} + +#[test] +fn test_windows_1250_central_european_text() { + let (encoded, _, had_errors) = WINDOWS_1250.encode("Příliš žluťoučký kůň"); + assert!(!had_errors, "failed to encode Central European sample"); + assert_eq!( + bytes_to_string_smart(encoded.as_ref()), + "Příliš žluťoučký kůň" + ); +} + +#[test] +fn test_windows_1251_encoded_text() { + let (encoded, _, had_errors) = WINDOWS_1251.encode("Привет из Windows-1251"); + assert!(!had_errors, "failed to encode Windows-1251 sample"); + assert_eq!( + bytes_to_string_smart(encoded.as_ref()), + "Привет из Windows-1251" + ); +} + +#[test] +fn test_windows_1253_greek_text() { + let (encoded, _, had_errors) = WINDOWS_1253.encode("Γειά σου"); + assert!(!had_errors, "failed to encode Greek sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Γειά σου"); +} + +#[test] +fn test_windows_1254_turkish_text() { + let (encoded, _, had_errors) = WINDOWS_1254.encode("İstanbul"); + assert!(!had_errors, "failed to encode Turkish sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "İstanbul"); +} + +#[test] +fn test_windows_1255_hebrew_text() { + let (encoded, _, had_errors) = WINDOWS_1255.encode("שלום"); + assert!(!had_errors, "failed to encode Windows-1255 Hebrew sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "שלום"); +} + +#[test] +fn test_windows_1256_arabic_text() { + let (encoded, _, had_errors) = WINDOWS_1256.encode("مرحبا"); + assert!(!had_errors, "failed to encode Windows-1256 Arabic sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "مرحبا"); +} + +#[test] +fn test_windows_1257_baltic_text() { + let (encoded, _, had_errors) = WINDOWS_1257.encode("Pērkons"); + assert!(!had_errors, "failed to encode Baltic sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Pērkons"); +} + +#[test] +fn test_windows_1258_vietnamese_text() { + let (encoded, _, had_errors) = WINDOWS_1258.encode("Xin chào"); + assert!(!had_errors, "failed to encode Vietnamese sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Xin chào"); +} + +#[test] +fn test_windows_874_thai_text() { + let (encoded, _, had_errors) = WINDOWS_874.encode("สวัสดีครับ นี่คือการทดสอบภาษาไทย"); + assert!(!had_errors, "failed to encode Thai sample"); + assert_eq!( + bytes_to_string_smart(encoded.as_ref()), + "สวัสดีครับ นี่คือการทดสอบภาษาไทย" + ); +} + +#[test] +fn test_windows_932_shift_jis_text() { + let (encoded, _, had_errors) = SHIFT_JIS.encode("こんにちは"); + assert!(!had_errors, "failed to encode Shift-JIS sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "こんにちは"); +} + +#[test] +fn test_windows_936_gbk_text() { + let (encoded, _, had_errors) = GBK.encode("你好,世界,这是一个测试"); + assert!(!had_errors, "failed to encode GBK sample"); + assert_eq!( + bytes_to_string_smart(encoded.as_ref()), + "你好,世界,这是一个测试" + ); +} + +#[test] +fn test_windows_949_korean_text() { + let (encoded, _, had_errors) = EUC_KR.encode("안녕하세요"); + assert!(!had_errors, "failed to encode Korean sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "안녕하세요"); +} + +#[test] +fn test_windows_950_big5_text() { + let (encoded, _, had_errors) = BIG5.encode("繁體"); + assert!(!had_errors, "failed to encode Big5 sample"); + assert_eq!(bytes_to_string_smart(encoded.as_ref()), "繁體"); +} + +#[test] +fn test_latin1_cafe() { + // Latin-1 bytes remain common in Western-European locales; decode them directly. + let bytes = b"caf\xE9"; // codespell:ignore caf + assert_eq!(bytes_to_string_smart(bytes), "café"); +} + +#[test] +fn test_preserves_ansi_sequences() { + // ANSI escape sequences should survive regardless of the detected encoding. + let bytes = b"\x1b[31mred\x1b[0m"; + assert_eq!(bytes_to_string_smart(bytes), "\x1b[31mred\x1b[0m"); +} + +#[test] +fn test_fallback_to_lossy() { + // Completely invalid sequences fall back to the old lossy behavior. + let invalid_bytes = [0xFF, 0xFE, 0xFD]; + let result = bytes_to_string_smart(&invalid_bytes); + assert_eq!(result, String::from_utf8_lossy(&invalid_bytes)); +} diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index d0a40755aa..3ed8e8f0b3 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -3,6 +3,7 @@ use crate::CodexAuth; use crate::ModelProviderInfo; use crate::agent::AgentControl; use crate::codex::Codex; +use crate::codex::CodexSpawnArgs; use crate::codex::CodexSpawnOk; use crate::codex::INITIAL_SUBMIT_ID; use crate::codex_thread::CodexThread; @@ -30,11 +31,15 @@ use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::W3cTraceContext; +use futures::StreamExt; +use futures::stream::FuturesUnordered; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +use std::time::Duration; use tokio::runtime::Handle; use tokio::runtime::RuntimeFlavor; use tokio::sync::RwLock; @@ -118,6 +123,19 @@ pub struct NewThread { pub session_configured: SessionConfiguredEvent, } +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ThreadShutdownReport { + pub completed: Vec, + pub submit_failed: Vec, + pub timed_out: Vec, +} + +enum ShutdownOutcome { + Complete, + SubmitFailed, + TimedOut, +} + /// [`ThreadManager`] is responsible for creating threads and maintaining /// them in memory. pub struct ThreadManager { @@ -329,6 +347,7 @@ impl ThreadManager { dynamic_tools, persist_extended_history, None, + None, )) .await } @@ -339,6 +358,7 @@ impl ThreadManager { dynamic_tools: Vec, persist_extended_history: bool, metrics_service_name: Option, + parent_trace: Option, ) -> CodexResult { Box::pin(self.state.spawn_thread( config, @@ -348,6 +368,7 @@ impl ThreadManager { dynamic_tools, persist_extended_history, metrics_service_name, + parent_trace, )) .await } @@ -357,10 +378,17 @@ impl ThreadManager { config: Config, rollout_path: PathBuf, auth_manager: Arc, + parent_trace: Option, ) -> CodexResult { let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?; - Box::pin(self.resume_thread_with_history(config, initial_history, auth_manager, false)) - .await + Box::pin(self.resume_thread_with_history( + config, + initial_history, + auth_manager, + false, + parent_trace, + )) + .await } pub async fn resume_thread_with_history( @@ -369,6 +397,7 @@ impl ThreadManager { initial_history: InitialHistory, auth_manager: Arc, persist_extended_history: bool, + parent_trace: Option, ) -> CodexResult { Box::pin(self.state.spawn_thread( config, @@ -378,6 +407,7 @@ impl ThreadManager { Vec::new(), persist_extended_history, None, + parent_trace, )) .await } @@ -389,13 +419,55 @@ impl ThreadManager { self.state.threads.write().await.remove(thread_id) } - /// Closes all threads open in this ThreadManager - pub async fn remove_and_close_all_threads(&self) -> CodexResult<()> { - for thread in self.state.threads.read().await.values() { - thread.submit(Op::Shutdown).await?; + /// Tries to shut down all tracked threads concurrently within the provided timeout. + /// Threads that complete shutdown are removed from the manager; incomplete shutdowns + /// remain tracked so callers can retry or inspect them later. + pub async fn shutdown_all_threads_bounded(&self, timeout: Duration) -> ThreadShutdownReport { + let threads = { + let threads = self.state.threads.read().await; + threads + .iter() + .map(|(thread_id, thread)| (*thread_id, Arc::clone(thread))) + .collect::>() + }; + + let mut shutdowns = threads + .into_iter() + .map(|(thread_id, thread)| async move { + let outcome = match tokio::time::timeout(timeout, thread.shutdown_and_wait()).await + { + Ok(Ok(())) => ShutdownOutcome::Complete, + Ok(Err(_)) => ShutdownOutcome::SubmitFailed, + Err(_) => ShutdownOutcome::TimedOut, + }; + (thread_id, outcome) + }) + .collect::>(); + let mut report = ThreadShutdownReport::default(); + + while let Some((thread_id, outcome)) = shutdowns.next().await { + match outcome { + ShutdownOutcome::Complete => report.completed.push(thread_id), + ShutdownOutcome::SubmitFailed => report.submit_failed.push(thread_id), + ShutdownOutcome::TimedOut => report.timed_out.push(thread_id), + } } - self.state.threads.write().await.clear(); - Ok(()) + + let mut tracked_threads = self.state.threads.write().await; + for thread_id in &report.completed { + tracked_threads.remove(thread_id); + } + + report + .completed + .sort_by_key(std::string::ToString::to_string); + report + .submit_failed + .sort_by_key(std::string::ToString::to_string); + report + .timed_out + .sort_by_key(std::string::ToString::to_string); + report } /// Fork an existing thread by taking messages up to the given position (not including @@ -408,6 +480,7 @@ impl ThreadManager { config: Config, path: PathBuf, persist_extended_history: bool, + parent_trace: Option, ) -> CodexResult { let history = RolloutRecorder::get_rollout_history(&path).await?; let history = truncate_before_nth_user_message(history, nth_user_message); @@ -419,6 +492,7 @@ impl ThreadManager { Vec::new(), persist_extended_history, None, + parent_trace, )) .await } @@ -503,6 +577,7 @@ impl ThreadManagerState { persist_extended_history, metrics_service_name, inherited_shell_snapshot, + None, )) .await } @@ -526,6 +601,7 @@ impl ThreadManagerState { false, None, inherited_shell_snapshot, + None, )) .await } @@ -549,6 +625,7 @@ impl ThreadManagerState { persist_extended_history, None, inherited_shell_snapshot, + None, )) .await } @@ -564,6 +641,7 @@ impl ThreadManagerState { dynamic_tools: Vec, persist_extended_history: bool, metrics_service_name: Option, + parent_trace: Option, ) -> CodexResult { Box::pin(self.spawn_thread_with_source( config, @@ -575,6 +653,7 @@ impl ThreadManagerState { persist_extended_history, metrics_service_name, None, + parent_trace, )) .await } @@ -591,28 +670,30 @@ impl ThreadManagerState { persist_extended_history: bool, metrics_service_name: Option, inherited_shell_snapshot: Option>, + parent_trace: Option, ) -> CodexResult { let watch_registration = self .file_watcher .register_config(&config, self.skills_manager.as_ref()); let CodexSpawnOk { codex, thread_id, .. - } = Codex::spawn( + } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - Arc::clone(&self.models_manager), - Arc::clone(&self.skills_manager), - Arc::clone(&self.plugins_manager), - Arc::clone(&self.mcp_manager), - Arc::clone(&self.file_watcher), - initial_history, + models_manager: Arc::clone(&self.models_manager), + skills_manager: Arc::clone(&self.skills_manager), + plugins_manager: Arc::clone(&self.plugins_manager), + mcp_manager: Arc::clone(&self.mcp_manager), + file_watcher: Arc::clone(&self.file_watcher), + conversation_history: initial_history, session_source, agent_control, dynamic_tools, persist_extended_history, metrics_service_name, inherited_shell_snapshot, - ) + parent_trace, + }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) .await @@ -669,119 +750,5 @@ fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> Initia } #[cfg(test)] -mod tests { - use super::*; - use crate::codex::make_session_and_context; - use assert_matches::assert_matches; - use codex_protocol::models::ContentItem; - use codex_protocol::models::ReasoningItemReasoningSummary; - use codex_protocol::models::ResponseItem; - use pretty_assertions::assert_eq; - - fn user_msg(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::OutputText { - text: text.to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - } - } - fn assistant_msg(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: text.to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - } - } - - #[test] - fn drops_from_last_user_only() { - let items = [ - user_msg("u1"), - assistant_msg("a1"), - assistant_msg("a2"), - user_msg("u2"), - assistant_msg("a3"), - ResponseItem::Reasoning { - id: "r1".to_string(), - summary: vec![ReasoningItemReasoningSummary::SummaryText { - text: "s".to_string(), - }], - content: None, - encrypted_content: None, - }, - ResponseItem::FunctionCall { - id: None, - call_id: "c1".to_string(), - name: "tool".to_string(), - arguments: "{}".to_string(), - }, - assistant_msg("a4"), - ]; - - let initial: Vec = items - .iter() - .cloned() - .map(RolloutItem::ResponseItem) - .collect(); - let truncated = truncate_before_nth_user_message(InitialHistory::Forked(initial), 1); - let got_items = truncated.get_rollout_items(); - let expected_items = vec![ - RolloutItem::ResponseItem(items[0].clone()), - RolloutItem::ResponseItem(items[1].clone()), - RolloutItem::ResponseItem(items[2].clone()), - ]; - assert_eq!( - serde_json::to_value(&got_items).unwrap(), - serde_json::to_value(&expected_items).unwrap() - ); - - let initial2: Vec = items - .iter() - .cloned() - .map(RolloutItem::ResponseItem) - .collect(); - let truncated2 = truncate_before_nth_user_message(InitialHistory::Forked(initial2), 2); - assert_matches!(truncated2, InitialHistory::New); - } - - #[tokio::test] - async fn ignores_session_prefix_messages_when_truncating() { - let (session, turn_context) = make_session_and_context().await; - let mut items = session.build_initial_context(&turn_context).await; - items.push(user_msg("feature request")); - items.push(assistant_msg("ack")); - items.push(user_msg("second question")); - items.push(assistant_msg("answer")); - - let rollout_items: Vec = items - .iter() - .cloned() - .map(RolloutItem::ResponseItem) - .collect(); - - let truncated = truncate_before_nth_user_message(InitialHistory::Forked(rollout_items), 1); - let got_items = truncated.get_rollout_items(); - - let expected: Vec = vec![ - RolloutItem::ResponseItem(items[0].clone()), - RolloutItem::ResponseItem(items[1].clone()), - RolloutItem::ResponseItem(items[2].clone()), - RolloutItem::ResponseItem(items[3].clone()), - ]; - - assert_eq!( - serde_json::to_value(&got_items).unwrap(), - serde_json::to_value(&expected).unwrap() - ); - } -} +#[path = "thread_manager_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs new file mode 100644 index 0000000000..cb52620317 --- /dev/null +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -0,0 +1,115 @@ +use super::*; +use crate::codex::make_session_and_context; +use assert_matches::assert_matches; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ReasoningItemReasoningSummary; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; + +fn user_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + } +} +fn assistant_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + } +} + +#[test] +fn drops_from_last_user_only() { + let items = [ + user_msg("u1"), + assistant_msg("a1"), + assistant_msg("a2"), + user_msg("u2"), + assistant_msg("a3"), + ResponseItem::Reasoning { + id: "r1".to_string(), + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "s".to_string(), + }], + content: None, + encrypted_content: None, + }, + ResponseItem::FunctionCall { + id: None, + call_id: "c1".to_string(), + name: "tool".to_string(), + namespace: None, + arguments: "{}".to_string(), + }, + assistant_msg("a4"), + ]; + + let initial: Vec = items + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect(); + let truncated = truncate_before_nth_user_message(InitialHistory::Forked(initial), 1); + let got_items = truncated.get_rollout_items(); + let expected_items = vec![ + RolloutItem::ResponseItem(items[0].clone()), + RolloutItem::ResponseItem(items[1].clone()), + RolloutItem::ResponseItem(items[2].clone()), + ]; + assert_eq!( + serde_json::to_value(&got_items).unwrap(), + serde_json::to_value(&expected_items).unwrap() + ); + + let initial2: Vec = items + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect(); + let truncated2 = truncate_before_nth_user_message(InitialHistory::Forked(initial2), 2); + assert_matches!(truncated2, InitialHistory::New); +} + +#[tokio::test] +async fn ignores_session_prefix_messages_when_truncating() { + let (session, turn_context) = make_session_and_context().await; + let mut items = session.build_initial_context(&turn_context).await; + items.push(user_msg("feature request")); + items.push(assistant_msg("ack")); + items.push(user_msg("second question")); + items.push(assistant_msg("answer")); + + let rollout_items: Vec = items + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect(); + + let truncated = truncate_before_nth_user_message(InitialHistory::Forked(rollout_items), 1); + let got_items = truncated.get_rollout_items(); + + let expected: Vec = vec![ + RolloutItem::ResponseItem(items[0].clone()), + RolloutItem::ResponseItem(items[1].clone()), + RolloutItem::ResponseItem(items[2].clone()), + RolloutItem::ResponseItem(items[3].clone()), + ]; + + assert_eq!( + serde_json::to_value(&got_items).unwrap(), + serde_json::to_value(&expected).unwrap() + ); +} diff --git a/codex-rs/core/src/token_data.rs b/codex-rs/core/src/token_data.rs index 85babd8511..5952d5940d 100644 --- a/codex-rs/core/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -175,114 +175,5 @@ where } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use serde::Serialize; - - #[test] - fn id_token_info_parses_email_and_plan() { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ - "email": "user@example.com", - "https://api.openai.com/auth": { - "chatgpt_plan_type": "pro" - } - }); - - fn b64url_no_pad(bytes: &[u8]) -> String { - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) - } - - let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); - let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); - let signature_b64 = b64url_no_pad(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse"); - assert_eq!(info.email.as_deref(), Some("user@example.com")); - assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Pro")); - } - - #[test] - fn id_token_info_parses_go_plan() { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ - "email": "user@example.com", - "https://api.openai.com/auth": { - "chatgpt_plan_type": "go" - } - }); - - fn b64url_no_pad(bytes: &[u8]) -> String { - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) - } - - let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); - let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); - let signature_b64 = b64url_no_pad(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse"); - assert_eq!(info.email.as_deref(), Some("user@example.com")); - assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Go")); - } - - #[test] - fn id_token_info_handles_missing_fields() { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ "sub": "123" }); - - fn b64url_no_pad(bytes: &[u8]) -> String { - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) - } - - let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); - let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); - let signature_b64 = b64url_no_pad(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse"); - assert!(info.email.is_none()); - assert!(info.get_chatgpt_plan_type().is_none()); - } - - #[test] - fn workspace_account_detection_matches_workspace_plans() { - let workspace = IdTokenInfo { - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Business)), - ..IdTokenInfo::default() - }; - assert_eq!(workspace.is_workspace_account(), true); - - let personal = IdTokenInfo { - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), - ..IdTokenInfo::default() - }; - assert_eq!(personal.is_workspace_account(), false); - } -} +#[path = "token_data_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/token_data_tests.rs b/codex-rs/core/src/token_data_tests.rs new file mode 100644 index 0000000000..e599379c18 --- /dev/null +++ b/codex-rs/core/src/token_data_tests.rs @@ -0,0 +1,109 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde::Serialize; + +#[test] +fn id_token_info_parses_email_and_plan() { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "pro" + } + }); + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64url_no_pad(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse"); + assert_eq!(info.email.as_deref(), Some("user@example.com")); + assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Pro")); +} + +#[test] +fn id_token_info_parses_go_plan() { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "go" + } + }); + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64url_no_pad(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse"); + assert_eq!(info.email.as_deref(), Some("user@example.com")); + assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Go")); +} + +#[test] +fn id_token_info_handles_missing_fields() { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ "sub": "123" }); + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64url_no_pad(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse"); + assert!(info.email.is_none()); + assert!(info.get_chatgpt_plan_type().is_none()); +} + +#[test] +fn workspace_account_detection_matches_workspace_plans() { + let workspace = IdTokenInfo { + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Business)), + ..IdTokenInfo::default() + }; + assert_eq!(workspace.is_workspace_account(), true); + + let personal = IdTokenInfo { + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + ..IdTokenInfo::default() + }; + assert_eq!(personal.is_workspace_account(), false); +} diff --git a/codex-rs/core/src/tools/code_mode.rs b/codex-rs/core/src/tools/code_mode.rs deleted file mode 100644 index ba8dd29e04..0000000000 --- a/codex-rs/core/src/tools/code_mode.rs +++ /dev/null @@ -1,535 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use crate::client_common::tools::ToolSpec; -use crate::codex::Session; -use crate::codex::TurnContext; -use crate::config::Config; -use crate::exec_env::create_env; -use crate::features::Feature; -use crate::function_tool::FunctionCallError; -use crate::tools::ToolRouter; -use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; -use crate::tools::code_mode_description::code_mode_tool_reference; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::SharedTurnDiffTracker; -use crate::tools::context::ToolPayload; -use crate::tools::js_repl::resolve_compatible_node; -use crate::tools::router::ToolCall; -use crate::tools::router::ToolCallSource; -use crate::truncate::TruncationPolicy; -use crate::truncate::formatted_truncate_text_content_items_with_policy; -use crate::truncate::truncate_function_output_items_with_policy; -use crate::unified_exec::resolve_max_tokens; -use codex_protocol::models::FunctionCallOutputContentItem; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value as JsonValue; -use tokio::io::AsyncBufReadExt; -use tokio::io::AsyncReadExt; -use tokio::io::AsyncWriteExt; -use tokio::io::BufReader; - -const CODE_MODE_RUNNER_SOURCE: &str = include_str!("code_mode_runner.cjs"); -const CODE_MODE_BRIDGE_SOURCE: &str = include_str!("code_mode_bridge.js"); -pub(crate) const PUBLIC_TOOL_NAME: &str = "exec"; - -#[derive(Clone)] -struct ExecContext { - session: Arc, - turn: Arc, - tracker: SharedTurnDiffTracker, -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -enum CodeModeToolKind { - Function, - Freeform, -} - -#[derive(Clone, Debug, Serialize)] -struct EnabledTool { - tool_name: String, - #[serde(rename = "module")] - module_path: String, - namespace: Vec, - name: String, - description: String, - kind: CodeModeToolKind, -} - -#[derive(Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum HostToNodeMessage { - Init { - enabled_tools: Vec, - stored_values: HashMap, - source: String, - }, - Response { - id: String, - code_mode_result: JsonValue, - }, -} - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum NodeToHostMessage { - ToolCall { - id: String, - name: String, - #[serde(default)] - input: Option, - }, - Result { - content_items: Vec, - stored_values: HashMap, - #[serde(default)] - error_text: Option, - #[serde(default)] - max_output_tokens_per_exec_call: Option, - }, -} - -pub(crate) fn instructions(config: &Config) -> Option { - if !config.features.enabled(Feature::CodeMode) { - return None; - } - - let mut section = String::from("## Exec\n"); - section.push_str(&format!( - "- Use `{PUBLIC_TOOL_NAME}` for JavaScript execution in a Node-backed `node:vm` context.\n", - )); - section.push_str(&format!( - "- `{PUBLIC_TOOL_NAME}` is a freeform/custom tool. Direct `{PUBLIC_TOOL_NAME}` calls must send raw JavaScript tool input. Do not wrap code in JSON, quotes, or markdown code fences.\n", - )); - section.push_str(&format!( - "- Direct tool calls remain available while `{PUBLIC_TOOL_NAME}` is enabled.\n", - )); - section.push_str(&format!( - "- `{PUBLIC_TOOL_NAME}` uses the same Node runtime resolution as `js_repl`. If needed, point `js_repl_node_path` at the Node binary you want Codex to use.\n", - )); - section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { ALL_TOOLS } from \"tools.js\"` to inspect the available `{ module, name, description }` entries. Namespaced tools are also available from `tools/.js`; MCP tools use `tools/mcp/.js`, for example `import { append_notebook_logs_chart } from \"tools/mcp/ologs.js\"`. Nested tool calls resolve to their code-mode result values.\n"); - section.push_str(&format!( - "- Import `{{ output_text, output_image, set_max_output_tokens_per_exec_call, store, load }}` from `@openai/code_mode` (or `\"openai/code_mode\"`). `output_text(value)` surfaces text back to the model and stringifies non-string objects with `JSON.stringify(...)` when possible. `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs. `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, and `load(key)` returns a cloned stored value or `undefined`. `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate the final Rust-side result of the current `{PUBLIC_TOOL_NAME}` execution; the default is `10000`. This guards the overall `{PUBLIC_TOOL_NAME}` output, not individual nested tool invocations. The returned content starts with a separate `Script completed` or `Script failed` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker.\n", - )); - section.push_str( - "- Function tools require JSON object arguments. Freeform tools require raw strings.\n", - ); - section.push_str("- `add_content(value)` remains available for compatibility. It is synchronous and accepts a content item, an array of content items, or a string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`.\n"); - section - .push_str("- Only content passed to `output_text(...)`, `output_image(...)`, or `add_content(value)` is surfaced back to the model."); - Some(section) -} - -pub(crate) async fn execute( - session: Arc, - turn: Arc, - tracker: SharedTurnDiffTracker, - code: String, -) -> Result { - let exec = ExecContext { - session, - turn, - tracker, - }; - let enabled_tools = build_enabled_tools(&exec).await; - let stored_values = exec.session.services.code_mode_store.stored_values().await; - let source = build_source(&code, &enabled_tools).map_err(FunctionCallError::RespondToModel)?; - execute_node(exec, source, enabled_tools, stored_values) - .await - .map_err(FunctionCallError::RespondToModel) -} - -async fn execute_node( - exec: ExecContext, - source: String, - enabled_tools: Vec, - stored_values: HashMap, -) -> Result { - let node_path = resolve_compatible_node(exec.turn.config.js_repl_node_path.as_deref()).await?; - let started_at = std::time::Instant::now(); - - let env = create_env(&exec.turn.shell_environment_policy, None); - let mut cmd = tokio::process::Command::new(&node_path); - cmd.arg("--experimental-vm-modules"); - cmd.arg("--eval"); - cmd.arg(CODE_MODE_RUNNER_SOURCE); - cmd.current_dir(&exec.turn.cwd); - cmd.env_clear(); - cmd.envs(env); - cmd.stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - - let mut child = cmd - .spawn() - .map_err(|err| format!("failed to start {PUBLIC_TOOL_NAME} Node runtime: {err}"))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| format!("{PUBLIC_TOOL_NAME} runner missing stdout"))?; - let stderr = child - .stderr - .take() - .ok_or_else(|| format!("{PUBLIC_TOOL_NAME} runner missing stderr"))?; - let mut stdin = child - .stdin - .take() - .ok_or_else(|| format!("{PUBLIC_TOOL_NAME} runner missing stdin"))?; - - let stderr_task = tokio::spawn(async move { - let mut reader = BufReader::new(stderr); - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf).await; - String::from_utf8_lossy(&buf).trim().to_string() - }); - - write_message( - &mut stdin, - &HostToNodeMessage::Init { - enabled_tools: enabled_tools.clone(), - stored_values, - source, - }, - ) - .await?; - - let mut stdout_lines = BufReader::new(stdout).lines(); - let mut pending_result = None; - while let Some(line) = stdout_lines - .next_line() - .await - .map_err(|err| format!("failed to read {PUBLIC_TOOL_NAME} runner stdout: {err}"))? - { - if line.trim().is_empty() { - continue; - } - let message: NodeToHostMessage = serde_json::from_str(&line).map_err(|err| { - format!("invalid {PUBLIC_TOOL_NAME} runner message: {err}; line={line}") - })?; - match message { - NodeToHostMessage::ToolCall { id, name, input } => { - let response = HostToNodeMessage::Response { - id, - code_mode_result: call_nested_tool(exec.clone(), name, input).await, - }; - write_message(&mut stdin, &response).await?; - } - NodeToHostMessage::Result { - content_items, - stored_values, - error_text, - max_output_tokens_per_exec_call, - } => { - exec.session - .services - .code_mode_store - .replace_stored_values(stored_values) - .await; - pending_result = Some(( - output_content_items_from_json_values(content_items)?, - error_text, - max_output_tokens_per_exec_call, - )); - break; - } - } - } - - drop(stdin); - - let status = child - .wait() - .await - .map_err(|err| format!("failed to wait for {PUBLIC_TOOL_NAME} runner: {err}"))?; - let stderr = stderr_task - .await - .map_err(|err| format!("failed to collect {PUBLIC_TOOL_NAME} stderr: {err}"))?; - let wall_time = started_at.elapsed(); - let success = status.success(); - - let Some((mut content_items, error_text, max_output_tokens_per_exec_call)) = pending_result - else { - let message = if stderr.is_empty() { - format!("{PUBLIC_TOOL_NAME} runner exited without returning a result (status {status})") - } else { - stderr - }; - return Err(message); - }; - - if !success { - let error_text = error_text.unwrap_or_else(|| { - if stderr.is_empty() { - format!("Process exited with status {status}") - } else { - stderr - } - }); - content_items.push(FunctionCallOutputContentItem::InputText { - text: format!("Script error:\n{error_text}"), - }); - } - - let mut content_items = - truncate_code_mode_result(content_items, max_output_tokens_per_exec_call); - prepend_script_status(&mut content_items, success, wall_time); - Ok(FunctionToolOutput::from_content( - content_items, - Some(success), - )) -} - -async fn write_message( - stdin: &mut tokio::process::ChildStdin, - message: &HostToNodeMessage, -) -> Result<(), String> { - let line = serde_json::to_string(message) - .map_err(|err| format!("failed to serialize {PUBLIC_TOOL_NAME} message: {err}"))?; - stdin - .write_all(line.as_bytes()) - .await - .map_err(|err| format!("failed to write {PUBLIC_TOOL_NAME} message: {err}"))?; - stdin - .write_all(b"\n") - .await - .map_err(|err| format!("failed to write {PUBLIC_TOOL_NAME} message newline: {err}"))?; - stdin - .flush() - .await - .map_err(|err| format!("failed to flush {PUBLIC_TOOL_NAME} message: {err}")) -} - -fn prepend_script_status( - content_items: &mut Vec, - success: bool, - wall_time: Duration, -) { - let wall_time_seconds = ((wall_time.as_secs_f32()) * 10.0).round() / 10.0; - let header = format!( - "{}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n", - if success { - "Script completed" - } else { - "Script failed" - } - ); - content_items.insert(0, FunctionCallOutputContentItem::InputText { text: header }); -} - -fn build_source(user_code: &str, enabled_tools: &[EnabledTool]) -> Result { - let enabled_tools_json = serde_json::to_string(enabled_tools) - .map_err(|err| format!("failed to serialize enabled tools: {err}"))?; - Ok(CODE_MODE_BRIDGE_SOURCE - .replace( - "__CODE_MODE_ENABLED_TOOLS_PLACEHOLDER__", - &enabled_tools_json, - ) - .replace("__CODE_MODE_USER_CODE_PLACEHOLDER__", user_code)) -} - -fn truncate_code_mode_result( - items: Vec, - max_output_tokens_per_exec_call: Option, -) -> Vec { - let max_output_tokens = resolve_max_tokens(max_output_tokens_per_exec_call); - let policy = TruncationPolicy::Tokens(max_output_tokens); - if items - .iter() - .all(|item| matches!(item, FunctionCallOutputContentItem::InputText { .. })) - { - let (truncated_items, _) = - formatted_truncate_text_content_items_with_policy(&items, policy); - return truncated_items; - } - - truncate_function_output_items_with_policy(&items, policy) -} - -async fn build_enabled_tools(exec: &ExecContext) -> Vec { - let router = build_nested_router(exec).await; - let mut out = router - .specs() - .into_iter() - .map(|spec| augment_tool_spec_for_code_mode(spec, true)) - .filter_map(enabled_tool_from_spec) - .collect::>(); - out.sort_by(|left, right| left.tool_name.cmp(&right.tool_name)); - out.dedup_by(|left, right| left.tool_name == right.tool_name); - out -} - -fn enabled_tool_from_spec(spec: ToolSpec) -> Option { - let tool_name = spec.name().to_string(); - if tool_name == PUBLIC_TOOL_NAME { - return None; - } - - let reference = code_mode_tool_reference(&tool_name); - - let (description, kind) = match spec { - ToolSpec::Function(tool) => (tool.description, CodeModeToolKind::Function), - ToolSpec::Freeform(tool) => (tool.description, CodeModeToolKind::Freeform), - ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::WebSearch { .. } => { - return None; - } - }; - - Some(EnabledTool { - tool_name, - module_path: reference.module_path, - namespace: reference.namespace, - name: reference.tool_key, - description, - kind, - }) -} - -async fn build_nested_router(exec: &ExecContext) -> ToolRouter { - let nested_tools_config = exec.turn.tools_config.for_code_mode_nested_tools(); - let mcp_tools = exec - .session - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await - .into_iter() - .map(|(name, tool_info)| (name, tool_info.tool)) - .collect(); - - ToolRouter::from_config( - &nested_tools_config, - Some(mcp_tools), - None, - exec.turn.dynamic_tools.as_slice(), - ) -} - -async fn call_nested_tool( - exec: ExecContext, - tool_name: String, - input: Option, -) -> JsonValue { - if tool_name == PUBLIC_TOOL_NAME { - return JsonValue::String(format!("{PUBLIC_TOOL_NAME} cannot invoke itself")); - } - - let router = build_nested_router(&exec).await; - - let specs = router.specs(); - let payload = if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name).await { - match serialize_function_tool_arguments(&tool_name, input) { - Ok(raw_arguments) => ToolPayload::Mcp { - server, - tool, - raw_arguments, - }, - Err(error) => return JsonValue::String(error), - } - } else { - match build_nested_tool_payload(&specs, &tool_name, input) { - Ok(payload) => payload, - Err(error) => return JsonValue::String(error), - } - }; - - let call = ToolCall { - tool_name: tool_name.clone(), - call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()), - payload, - }; - let result = router - .dispatch_tool_call_with_code_mode_result( - Arc::clone(&exec.session), - Arc::clone(&exec.turn), - Arc::clone(&exec.tracker), - call, - ToolCallSource::CodeMode, - ) - .await; - - match result { - Ok(result) => result.code_mode_result(), - Err(error) => JsonValue::String(error.to_string()), - } -} - -fn tool_kind_for_spec(spec: &ToolSpec) -> CodeModeToolKind { - if matches!(spec, ToolSpec::Freeform(_)) { - CodeModeToolKind::Freeform - } else { - CodeModeToolKind::Function - } -} - -fn tool_kind_for_name(specs: &[ToolSpec], tool_name: &str) -> Result { - specs - .iter() - .find(|spec| spec.name() == tool_name) - .map(tool_kind_for_spec) - .ok_or_else(|| format!("tool `{tool_name}` is not enabled in {PUBLIC_TOOL_NAME}")) -} - -fn build_nested_tool_payload( - specs: &[ToolSpec], - tool_name: &str, - input: Option, -) -> Result { - let actual_kind = tool_kind_for_name(specs, tool_name)?; - match actual_kind { - CodeModeToolKind::Function => build_function_tool_payload(tool_name, input), - CodeModeToolKind::Freeform => build_freeform_tool_payload(tool_name, input), - } -} - -fn build_function_tool_payload( - tool_name: &str, - input: Option, -) -> Result { - let arguments = serialize_function_tool_arguments(tool_name, input)?; - Ok(ToolPayload::Function { arguments }) -} - -fn serialize_function_tool_arguments( - tool_name: &str, - input: Option, -) -> Result { - match input { - None => Ok("{}".to_string()), - Some(JsonValue::Object(map)) => serde_json::to_string(&JsonValue::Object(map)) - .map_err(|err| format!("failed to serialize tool `{tool_name}` arguments: {err}")), - Some(_) => Err(format!( - "tool `{tool_name}` expects a JSON object for arguments" - )), - } -} - -fn build_freeform_tool_payload( - tool_name: &str, - input: Option, -) -> Result { - match input { - Some(JsonValue::String(input)) => Ok(ToolPayload::Custom { input }), - _ => Err(format!("tool `{tool_name}` expects a string input")), - } -} - -fn output_content_items_from_json_values( - content_items: Vec, -) -> Result, String> { - content_items - .into_iter() - .enumerate() - .map(|(index, item)| { - serde_json::from_value(item).map_err(|err| { - format!("invalid {PUBLIC_TOOL_NAME} content item at index {index}: {err}") - }) - }) - .collect() -} diff --git a/codex-rs/core/src/tools/code_mode_bridge.js b/codex-rs/core/src/tools/code_mode/bridge.js similarity index 100% rename from codex-rs/core/src/tools/code_mode_bridge.js rename to codex-rs/core/src/tools/code_mode/bridge.js diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs new file mode 100644 index 0000000000..493c638da4 --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -0,0 +1,111 @@ +use async_trait::async_trait; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::SharedTurnDiffTracker; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +use super::CodeModeSessionProgress; +use super::ExecContext; +use super::PUBLIC_TOOL_NAME; +use super::build_enabled_tools; +use super::handle_node_message; +use super::protocol::HostToNodeMessage; +use super::protocol::build_source; + +pub struct CodeModeExecuteHandler; + +impl CodeModeExecuteHandler { + async fn execute( + &self, + session: std::sync::Arc, + turn: std::sync::Arc, + tracker: SharedTurnDiffTracker, + code: String, + ) -> Result { + let exec = ExecContext { + session, + turn, + tracker, + }; + let enabled_tools = build_enabled_tools(&exec).await; + let service = &exec.session.services.code_mode_service; + let stored_values = service.stored_values().await; + let source = + build_source(&code, &enabled_tools).map_err(FunctionCallError::RespondToModel)?; + let session_id = service.allocate_session_id().await; + let request_id = service.allocate_request_id().await; + let process_slot = service + .ensure_started() + .await + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; + let started_at = std::time::Instant::now(); + let message = HostToNodeMessage::Start { + request_id: request_id.clone(), + session_id, + enabled_tools, + stored_values, + source, + }; + let result = { + let mut process_slot = process_slot; + let Some(process) = process_slot.as_mut() else { + return Err(FunctionCallError::RespondToModel(format!( + "{PUBLIC_TOOL_NAME} runner failed to start" + ))); + }; + let message = process + .send(&request_id, &message) + .await + .map_err(|err| err.to_string()); + let message = match message { + Ok(message) => message, + Err(error) => return Err(FunctionCallError::RespondToModel(error)), + }; + handle_node_message(&exec, session_id, message, None, started_at).await + }; + match result { + Ok(CodeModeSessionProgress::Finished(output)) + | Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output), + Err(error) => Err(FunctionCallError::RespondToModel(error)), + } + } +} + +#[async_trait] +impl ToolHandler for CodeModeExecuteHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Custom { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tracker, + tool_name, + payload, + .. + } = invocation; + + match payload { + ToolPayload::Custom { input } if tool_name == PUBLIC_TOOL_NAME => { + self.execute(session, turn, tracker, input).await + } + _ => Err(FunctionCallError::RespondToModel(format!( + "{PUBLIC_TOOL_NAME} expects raw JavaScript source text" + ))), + } + } +} diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs new file mode 100644 index 0000000000..1b51cfc2fa --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -0,0 +1,399 @@ +mod execute_handler; +mod process; +mod protocol; +mod service; +mod wait_handler; +mod worker; + +use std::sync::Arc; +use std::time::Duration; + +use codex_protocol::models::FunctionCallOutputContentItem; +use serde_json::Value as JsonValue; + +use crate::client_common::tools::ToolSpec; +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::config::Config; +use crate::features::Feature; +use crate::tools::ToolRouter; +use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; +use crate::tools::code_mode_description::code_mode_tool_reference; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::SharedTurnDiffTracker; +use crate::tools::context::ToolPayload; +use crate::tools::router::ToolCall; +use crate::tools::router::ToolCallSource; +use crate::tools::router::ToolRouterParams; +use crate::truncate::TruncationPolicy; +use crate::truncate::formatted_truncate_text_content_items_with_policy; +use crate::truncate::truncate_function_output_items_with_policy; +use crate::unified_exec::resolve_max_tokens; + +const CODE_MODE_RUNNER_SOURCE: &str = include_str!("runner.cjs"); +const CODE_MODE_BRIDGE_SOURCE: &str = include_str!("bridge.js"); + +pub(crate) const PUBLIC_TOOL_NAME: &str = "exec"; +pub(crate) const WAIT_TOOL_NAME: &str = "exec_wait"; +pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; + +#[derive(Clone)] +pub(super) struct ExecContext { + pub(super) session: Arc, + pub(super) turn: Arc, + pub(super) tracker: SharedTurnDiffTracker, +} + +pub(crate) use execute_handler::CodeModeExecuteHandler; +pub(crate) use service::CodeModeService; +pub(crate) use wait_handler::CodeModeWaitHandler; + +enum CodeModeSessionProgress { + Finished(FunctionToolOutput), + Yielded { output: FunctionToolOutput }, +} + +enum CodeModeExecutionStatus { + Completed, + Failed, + Running(i32), + Terminated, +} + +pub(crate) fn instructions(config: &Config) -> Option { + if !config.features.enabled(Feature::CodeMode) { + return None; + } + + let mut section = String::from("## Exec\n"); + section.push_str(&format!( + "- Use `{PUBLIC_TOOL_NAME}` for JavaScript execution in a Node-backed `node:vm` context.\n", + )); + section.push_str(&format!( + "- `{PUBLIC_TOOL_NAME}` is a freeform/custom tool. Direct `{PUBLIC_TOOL_NAME}` calls must send raw JavaScript tool input. Do not wrap code in JSON, quotes, or markdown code fences.\n", + )); + section.push_str(&format!( + "- Direct tool calls remain available while `{PUBLIC_TOOL_NAME}` is enabled.\n", + )); + section.push_str(&format!( + "- `{PUBLIC_TOOL_NAME}` uses the same Node runtime resolution as `js_repl`. If needed, point `js_repl_node_path` at the Node binary you want Codex to use.\n", + )); + section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { ALL_TOOLS } from \"tools.js\"` to inspect the available `{ module, name, description }` entries. Namespaced tools are also available from `tools/.js`; MCP tools use `tools/mcp/.js`, for example `import { append_notebook_logs_chart } from \"tools/mcp/ologs.js\"`. Nested tool calls resolve to their code-mode result values.\n"); + section.push_str(&format!( + "- Import `{{ background, output_text, output_image, set_max_output_tokens_per_exec_call, set_yield_time, store, load }}` from `@openai/code_mode` (or `\"openai/code_mode\"`). `output_text(value)` surfaces text back to the model and stringifies non-string objects with `JSON.stringify(...)` when possible. `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs. `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, and `load(key)` returns a cloned stored value or `undefined`. `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate direct `{PUBLIC_TOOL_NAME}` returns; `{WAIT_TOOL_NAME}` uses its own `max_tokens` argument instead and defaults to `10000`. `set_yield_time(value)` asks `{PUBLIC_TOOL_NAME}` to return early if the script is still running after that many milliseconds so `{WAIT_TOOL_NAME}` can resume it later. `background()` returns a yielded `{PUBLIC_TOOL_NAME}` response immediately while the script keeps running in the background. The returned content starts with a separate `Script completed`, `Script failed`, or `Script running with session ID …` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker.\n", + )); + section.push_str(&format!( + "- If `{PUBLIC_TOOL_NAME}` returns `Script running with session ID …`, call `{WAIT_TOOL_NAME}` with that `session_id` to keep waiting for more output, completion, or termination.\n", + )); + section.push_str( + "- Function tools require JSON object arguments. Freeform tools require raw strings.\n", + ); + section.push_str("- `add_content(value)` remains available for compatibility. It is synchronous and accepts a content item, an array of content items, or a string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`.\n"); + section + .push_str("- Only content passed to `output_text(...)`, `output_image(...)`, or `add_content(value)` is surfaced back to the model."); + Some(section) +} + +async fn handle_node_message( + exec: &ExecContext, + session_id: i32, + message: protocol::NodeToHostMessage, + poll_max_output_tokens: Option>, + started_at: std::time::Instant, +) -> Result { + match message { + protocol::NodeToHostMessage::ToolCall { .. } => Err(protocol::unexpected_tool_call_error()), + protocol::NodeToHostMessage::Yielded { content_items, .. } => { + let mut delta_items = output_content_items_from_json_values(content_items)?; + delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten()); + prepend_script_status( + &mut delta_items, + CodeModeExecutionStatus::Running(session_id), + started_at.elapsed(), + ); + Ok(CodeModeSessionProgress::Yielded { + output: FunctionToolOutput::from_content(delta_items, Some(true)), + }) + } + protocol::NodeToHostMessage::Terminated { content_items, .. } => { + let mut delta_items = output_content_items_from_json_values(content_items)?; + delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten()); + prepend_script_status( + &mut delta_items, + CodeModeExecutionStatus::Terminated, + started_at.elapsed(), + ); + Ok(CodeModeSessionProgress::Finished( + FunctionToolOutput::from_content(delta_items, Some(true)), + )) + } + protocol::NodeToHostMessage::Result { + content_items, + stored_values, + error_text, + max_output_tokens_per_exec_call, + .. + } => { + exec.session + .services + .code_mode_service + .replace_stored_values(stored_values) + .await; + let mut delta_items = output_content_items_from_json_values(content_items)?; + let success = error_text.is_none(); + if let Some(error_text) = error_text { + delta_items.push(FunctionCallOutputContentItem::InputText { + text: format!("Script error:\n{error_text}"), + }); + } + + let mut delta_items = truncate_code_mode_result( + delta_items, + poll_max_output_tokens.unwrap_or(max_output_tokens_per_exec_call), + ); + prepend_script_status( + &mut delta_items, + if success { + CodeModeExecutionStatus::Completed + } else { + CodeModeExecutionStatus::Failed + }, + started_at.elapsed(), + ); + Ok(CodeModeSessionProgress::Finished( + FunctionToolOutput::from_content(delta_items, Some(success)), + )) + } + } +} + +fn prepend_script_status( + content_items: &mut Vec, + status: CodeModeExecutionStatus, + wall_time: Duration, +) { + let wall_time_seconds = ((wall_time.as_secs_f32()) * 10.0).round() / 10.0; + let header = format!( + "{}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n", + match status { + CodeModeExecutionStatus::Completed => "Script completed".to_string(), + CodeModeExecutionStatus::Failed => "Script failed".to_string(), + CodeModeExecutionStatus::Running(session_id) => { + format!("Script running with session ID {session_id}") + } + CodeModeExecutionStatus::Terminated => "Script terminated".to_string(), + } + ); + content_items.insert(0, FunctionCallOutputContentItem::InputText { text: header }); +} + +fn truncate_code_mode_result( + items: Vec, + max_output_tokens_per_exec_call: Option, +) -> Vec { + let max_output_tokens = resolve_max_tokens(max_output_tokens_per_exec_call); + let policy = TruncationPolicy::Tokens(max_output_tokens); + if items + .iter() + .all(|item| matches!(item, FunctionCallOutputContentItem::InputText { .. })) + { + let (truncated_items, _) = + formatted_truncate_text_content_items_with_policy(&items, policy); + return truncated_items; + } + + truncate_function_output_items_with_policy(&items, policy) +} + +fn output_content_items_from_json_values( + content_items: Vec, +) -> Result, String> { + content_items + .into_iter() + .enumerate() + .map(|(index, item)| { + serde_json::from_value(item).map_err(|err| { + format!("invalid {PUBLIC_TOOL_NAME} content item at index {index}: {err}") + }) + }) + .collect() +} + +async fn build_enabled_tools(exec: &ExecContext) -> Vec { + let router = build_nested_router(exec).await; + let mut out = router + .specs() + .into_iter() + .map(|spec| augment_tool_spec_for_code_mode(spec, true)) + .filter_map(enabled_tool_from_spec) + .collect::>(); + out.sort_by(|left, right| left.tool_name.cmp(&right.tool_name)); + out.dedup_by(|left, right| left.tool_name == right.tool_name); + out +} + +fn enabled_tool_from_spec(spec: ToolSpec) -> Option { + let tool_name = spec.name().to_string(); + if tool_name == PUBLIC_TOOL_NAME || tool_name == WAIT_TOOL_NAME { + return None; + } + + let reference = code_mode_tool_reference(&tool_name); + let (description, kind) = match spec { + ToolSpec::Function(tool) => (tool.description, protocol::CodeModeToolKind::Function), + ToolSpec::Freeform(tool) => (tool.description, protocol::CodeModeToolKind::Freeform), + ToolSpec::LocalShell {} + | ToolSpec::ImageGeneration { .. } + | ToolSpec::ToolSearch { .. } + | ToolSpec::WebSearch { .. } => { + return None; + } + }; + + Some(protocol::EnabledTool { + tool_name, + module_path: reference.module_path, + namespace: reference.namespace, + name: reference.tool_key, + description, + kind, + }) +} + +async fn build_nested_router(exec: &ExecContext) -> ToolRouter { + let nested_tools_config = exec.turn.tools_config.for_code_mode_nested_tools(); + let mcp_tools = exec + .session + .services + .mcp_connection_manager + .read() + .await + .list_all_tools() + .await + .into_iter() + .map(|(name, tool_info)| (name, tool_info.tool)) + .collect(); + + ToolRouter::from_config( + &nested_tools_config, + ToolRouterParams { + mcp_tools: Some(mcp_tools), + app_tools: None, + discoverable_tools: None, + dynamic_tools: exec.turn.dynamic_tools.as_slice(), + }, + ) +} + +async fn call_nested_tool( + exec: ExecContext, + tool_name: String, + input: Option, +) -> JsonValue { + if tool_name == PUBLIC_TOOL_NAME { + return JsonValue::String(format!("{PUBLIC_TOOL_NAME} cannot invoke itself")); + } + + let router = build_nested_router(&exec).await; + let specs = router.specs(); + let payload = + if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name, &None).await { + match serialize_function_tool_arguments(&tool_name, input) { + Ok(raw_arguments) => ToolPayload::Mcp { + server, + tool, + raw_arguments, + }, + Err(error) => return JsonValue::String(error), + } + } else { + match build_nested_tool_payload(&specs, &tool_name, input) { + Ok(payload) => payload, + Err(error) => return JsonValue::String(error), + } + }; + + let call = ToolCall { + tool_name: tool_name.clone(), + call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()), + tool_namespace: None, + payload, + }; + let result = router + .dispatch_tool_call_with_code_mode_result( + exec.session.clone(), + exec.turn.clone(), + exec.tracker.clone(), + call, + ToolCallSource::CodeMode, + ) + .await; + + match result { + Ok(result) => result.code_mode_result(), + Err(error) => JsonValue::String(error.to_string()), + } +} + +fn tool_kind_for_spec(spec: &ToolSpec) -> protocol::CodeModeToolKind { + if matches!(spec, ToolSpec::Freeform(_)) { + protocol::CodeModeToolKind::Freeform + } else { + protocol::CodeModeToolKind::Function + } +} + +fn tool_kind_for_name( + specs: &[ToolSpec], + tool_name: &str, +) -> Result { + specs + .iter() + .find(|spec| spec.name() == tool_name) + .map(tool_kind_for_spec) + .ok_or_else(|| format!("tool `{tool_name}` is not enabled in {PUBLIC_TOOL_NAME}")) +} + +fn build_nested_tool_payload( + specs: &[ToolSpec], + tool_name: &str, + input: Option, +) -> Result { + let actual_kind = tool_kind_for_name(specs, tool_name)?; + match actual_kind { + protocol::CodeModeToolKind::Function => build_function_tool_payload(tool_name, input), + protocol::CodeModeToolKind::Freeform => build_freeform_tool_payload(tool_name, input), + } +} + +fn build_function_tool_payload( + tool_name: &str, + input: Option, +) -> Result { + let arguments = serialize_function_tool_arguments(tool_name, input)?; + Ok(ToolPayload::Function { arguments }) +} + +fn serialize_function_tool_arguments( + tool_name: &str, + input: Option, +) -> Result { + match input { + None => Ok("{}".to_string()), + Some(JsonValue::Object(map)) => serde_json::to_string(&JsonValue::Object(map)) + .map_err(|err| format!("failed to serialize tool `{tool_name}` arguments: {err}")), + Some(_) => Err(format!( + "tool `{tool_name}` expects a JSON object for arguments" + )), + } +} + +fn build_freeform_tool_payload( + tool_name: &str, + input: Option, +) -> Result { + match input { + Some(JsonValue::String(input)) => Ok(ToolPayload::Custom { input }), + _ => Err(format!("tool `{tool_name}` expects a string input")), + } +} diff --git a/codex-rs/core/src/tools/code_mode/process.rs b/codex-rs/core/src/tools/code_mode/process.rs new file mode 100644 index 0000000000..d27296fca9 --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/process.rs @@ -0,0 +1,172 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tracing::warn; + +use super::CODE_MODE_RUNNER_SOURCE; +use super::PUBLIC_TOOL_NAME; +use super::protocol::CodeModeToolCall; +use super::protocol::HostToNodeMessage; +use super::protocol::NodeToHostMessage; +use super::protocol::message_request_id; + +pub(super) struct CodeModeProcess { + pub(super) child: tokio::process::Child, + pub(super) stdin: Arc>, + pub(super) stdout_task: JoinHandle<()>, + pub(super) response_waiters: Arc>>>, + pub(super) tool_call_rx: Arc>>, +} + +impl CodeModeProcess { + pub(super) async fn send( + &mut self, + request_id: &str, + message: &HostToNodeMessage, + ) -> Result { + if self.stdout_task.is_finished() { + return Err(std::io::Error::other(format!( + "{PUBLIC_TOOL_NAME} runner is not available" + ))); + } + + let (tx, rx) = oneshot::channel(); + self.response_waiters + .lock() + .await + .insert(request_id.to_string(), tx); + if let Err(err) = write_message(&self.stdin, message).await { + self.response_waiters.lock().await.remove(request_id); + return Err(err); + } + + match rx.await { + Ok(message) => Ok(message), + Err(_) => Err(std::io::Error::other(format!( + "{PUBLIC_TOOL_NAME} runner is not available" + ))), + } + } + + pub(super) fn has_exited(&mut self) -> Result { + self.child + .try_wait() + .map(|status| status.is_some()) + .map_err(std::io::Error::other) + } +} + +pub(super) async fn spawn_code_mode_process( + node_path: &std::path::Path, +) -> Result { + let mut cmd = tokio::process::Command::new(node_path); + cmd.arg("--experimental-vm-modules"); + cmd.arg("--eval"); + cmd.arg(CODE_MODE_RUNNER_SOURCE); + cmd.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + + let mut child = cmd.spawn().map_err(std::io::Error::other)?; + let stdout = child.stdout.take().ok_or_else(|| { + std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stdout")) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stderr")) + })?; + let stdin = child + .stdin + .take() + .ok_or_else(|| std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stdin")))?; + let stdin = Arc::new(Mutex::new(stdin)); + let response_waiters = Arc::new(Mutex::new(HashMap::< + String, + oneshot::Sender, + >::new())); + let (tool_call_tx, tool_call_rx) = mpsc::unbounded_channel(); + + tokio::spawn(async move { + let mut reader = BufReader::new(stderr); + let mut buf = Vec::new(); + match reader.read_to_end(&mut buf).await { + Ok(_) => { + let stderr = String::from_utf8_lossy(&buf).trim().to_string(); + if !stderr.is_empty() { + warn!("{PUBLIC_TOOL_NAME} runner stderr: {stderr}"); + } + } + Err(err) => { + warn!("failed to read {PUBLIC_TOOL_NAME} stderr: {err}"); + } + } + }); + let stdout_task = tokio::spawn({ + let response_waiters = Arc::clone(&response_waiters); + async move { + let mut stdout_lines = BufReader::new(stdout).lines(); + loop { + let line = match stdout_lines.next_line().await { + Ok(line) => line, + Err(err) => { + warn!("failed to read {PUBLIC_TOOL_NAME} stdout: {err}"); + break; + } + }; + let Some(line) = line else { + break; + }; + if line.trim().is_empty() { + continue; + } + let message: NodeToHostMessage = match serde_json::from_str(&line) { + Ok(message) => message, + Err(err) => { + warn!("failed to parse {PUBLIC_TOOL_NAME} stdout message: {err}"); + break; + } + }; + match message { + NodeToHostMessage::ToolCall { tool_call } => { + let _ = tool_call_tx.send(tool_call); + } + message => { + let request_id = message_request_id(&message).to_string(); + if let Some(waiter) = response_waiters.lock().await.remove(&request_id) { + let _ = waiter.send(message); + } + } + } + } + response_waiters.lock().await.clear(); + } + }); + + Ok(CodeModeProcess { + child, + stdin, + stdout_task, + response_waiters, + tool_call_rx: Arc::new(Mutex::new(tool_call_rx)), + }) +} + +pub(super) async fn write_message( + stdin: &Arc>, + message: &HostToNodeMessage, +) -> Result<(), std::io::Error> { + let line = serde_json::to_string(message).map_err(std::io::Error::other)?; + let mut stdin = stdin.lock().await; + stdin.write_all(line.as_bytes()).await?; + stdin.write_all(b"\n").await?; + stdin.flush().await?; + Ok(()) +} diff --git a/codex-rs/core/src/tools/code_mode/protocol.rs b/codex-rs/core/src/tools/code_mode/protocol.rs new file mode 100644 index 0000000000..fe0ab861f3 --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/protocol.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; + +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; + +use super::CODE_MODE_BRIDGE_SOURCE; +use super::PUBLIC_TOOL_NAME; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum CodeModeToolKind { + Function, + Freeform, +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct EnabledTool { + pub(super) tool_name: String, + #[serde(rename = "module")] + pub(super) module_path: String, + pub(super) namespace: Vec, + pub(super) name: String, + pub(super) description: String, + pub(super) kind: CodeModeToolKind, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) struct CodeModeToolCall { + pub(super) request_id: String, + pub(super) id: String, + pub(super) name: String, + #[serde(default)] + pub(super) input: Option, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum HostToNodeMessage { + Start { + request_id: String, + session_id: i32, + enabled_tools: Vec, + stored_values: HashMap, + source: String, + }, + Poll { + request_id: String, + session_id: i32, + yield_time_ms: u64, + }, + Terminate { + request_id: String, + session_id: i32, + }, + Response { + request_id: String, + id: String, + code_mode_result: JsonValue, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum NodeToHostMessage { + ToolCall { + #[serde(flatten)] + tool_call: CodeModeToolCall, + }, + Yielded { + request_id: String, + content_items: Vec, + }, + Terminated { + request_id: String, + content_items: Vec, + }, + Result { + request_id: String, + content_items: Vec, + stored_values: HashMap, + #[serde(default)] + error_text: Option, + #[serde(default)] + max_output_tokens_per_exec_call: Option, + }, +} + +pub(super) fn build_source( + user_code: &str, + enabled_tools: &[EnabledTool], +) -> Result { + let enabled_tools_json = serde_json::to_string(enabled_tools) + .map_err(|err| format!("failed to serialize enabled tools: {err}"))?; + Ok(CODE_MODE_BRIDGE_SOURCE + .replace( + "__CODE_MODE_ENABLED_TOOLS_PLACEHOLDER__", + &enabled_tools_json, + ) + .replace("__CODE_MODE_USER_CODE_PLACEHOLDER__", user_code)) +} + +pub(super) fn message_request_id(message: &NodeToHostMessage) -> &str { + match message { + NodeToHostMessage::ToolCall { tool_call } => &tool_call.request_id, + NodeToHostMessage::Yielded { request_id, .. } + | NodeToHostMessage::Terminated { request_id, .. } + | NodeToHostMessage::Result { request_id, .. } => request_id, + } +} + +pub(super) fn unexpected_tool_call_error() -> String { + format!("{PUBLIC_TOOL_NAME} received an unexpected tool call response") +} diff --git a/codex-rs/core/src/tools/code_mode/runner.cjs b/codex-rs/core/src/tools/code_mode/runner.cjs new file mode 100644 index 0000000000..02255b917c --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/runner.cjs @@ -0,0 +1,795 @@ +'use strict'; + +const readline = require('node:readline'); +const { Worker } = require('node:worker_threads'); + +const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL = 10000; + +function normalizeMaxOutputTokensPerExecCall(value) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError('max_output_tokens_per_exec_call must be a non-negative safe integer'); + } + return value; +} + +function normalizeYieldTime(value) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError('yield_time must be a non-negative safe integer'); + } + return value; +} + +function formatErrorText(error) { + return String(error && error.stack ? error.stack : error); +} + +function cloneJsonValue(value) { + return JSON.parse(JSON.stringify(value)); +} + +function clearTimer(timer) { + if (timer !== null) { + clearTimeout(timer); + } + return null; +} + +function takeContentItems(session) { + const clonedContentItems = cloneJsonValue(session.content_items); + session.content_items.splice(0, session.content_items.length); + return Array.isArray(clonedContentItems) ? clonedContentItems : []; +} + +function codeModeWorkerMain() { + 'use strict'; + + const { parentPort, workerData } = require('node:worker_threads'); + const vm = require('node:vm'); + const { SourceTextModule, SyntheticModule } = vm; + + const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL = 10000; + + function normalizeMaxOutputTokensPerExecCall(value) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError('max_output_tokens_per_exec_call must be a non-negative safe integer'); + } + return value; + } + + function normalizeYieldTime(value) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError('yield_time must be a non-negative safe integer'); + } + return value; + } + + function formatErrorText(error) { + return String(error && error.stack ? error.stack : error); + } + + function cloneJsonValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function createToolCaller() { + let nextId = 0; + const pending = new Map(); + + parentPort.on('message', (message) => { + if (message.type === 'tool_response') { + const entry = pending.get(message.id); + if (!entry) { + return; + } + pending.delete(message.id); + entry.resolve(message.result ?? ''); + return; + } + + if (message.type === 'tool_response_error') { + const entry = pending.get(message.id); + if (!entry) { + return; + } + pending.delete(message.id); + entry.reject(new Error(message.error_text ?? 'tool call failed')); + return; + } + }); + + return (name, input) => { + const id = 'msg-' + ++nextId; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + parentPort.postMessage({ + type: 'tool_call', + id, + name: String(name), + input, + }); + }); + }; + } + + function createContentItems() { + const contentItems = []; + const push = contentItems.push.bind(contentItems); + contentItems.push = (...items) => { + for (const item of items) { + parentPort.postMessage({ + type: 'content_item', + item: cloneJsonValue(item), + }); + } + return push(...items); + }; + parentPort.on('message', (message) => { + if (message.type === 'clear_content') { + contentItems.splice(0, contentItems.length); + } + }); + return contentItems; + } + + function createToolsNamespace(callTool, enabledTools) { + const tools = Object.create(null); + + for (const { tool_name } of enabledTools) { + Object.defineProperty(tools, tool_name, { + value: async (args) => callTool(tool_name, args), + configurable: false, + enumerable: true, + writable: false, + }); + } + + return Object.freeze(tools); + } + + function createAllToolsMetadata(enabledTools) { + return Object.freeze( + enabledTools.map(({ module: modulePath, name, description }) => + Object.freeze({ + module: modulePath, + name, + description, + }) + ) + ); + } + + function createToolsModule(context, callTool, enabledTools) { + const tools = createToolsNamespace(callTool, enabledTools); + const allTools = createAllToolsMetadata(enabledTools); + const exportNames = ['ALL_TOOLS']; + + for (const { tool_name } of enabledTools) { + if (tool_name !== 'ALL_TOOLS') { + exportNames.push(tool_name); + } + } + + const uniqueExportNames = [...new Set(exportNames)]; + + return new SyntheticModule( + uniqueExportNames, + function initToolsModule() { + this.setExport('ALL_TOOLS', allTools); + for (const exportName of uniqueExportNames) { + if (exportName !== 'ALL_TOOLS') { + this.setExport(exportName, tools[exportName]); + } + } + }, + { context } + ); + } + + function ensureContentItems(context) { + if (!Array.isArray(context.__codexContentItems)) { + context.__codexContentItems = []; + } + return context.__codexContentItems; + } + + function serializeOutputText(value) { + if (typeof value === 'string') { + return value; + } + if ( + typeof value === 'undefined' || + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'bigint' + ) { + return String(value); + } + + const serialized = JSON.stringify(value); + if (typeof serialized === 'string') { + return serialized; + } + + return String(value); + } + + function normalizeOutputImageUrl(value) { + if (typeof value !== 'string' || !value) { + throw new TypeError('output_image expects a non-empty image URL string'); + } + if (/^(?:https?:\/\/|data:)/i.test(value)) { + return value; + } + throw new TypeError('output_image expects an http(s) or data URL'); + } + + function createCodeModeModule(context, state) { + const load = (key) => { + if (typeof key !== 'string') { + throw new TypeError('load key must be a string'); + } + if (!Object.prototype.hasOwnProperty.call(state.storedValues, key)) { + return undefined; + } + return cloneJsonValue(state.storedValues[key]); + }; + const store = (key, value) => { + if (typeof key !== 'string') { + throw new TypeError('store key must be a string'); + } + state.storedValues[key] = cloneJsonValue(value); + }; + const outputText = (value) => { + const item = { + type: 'input_text', + text: serializeOutputText(value), + }; + ensureContentItems(context).push(item); + return item; + }; + const outputImage = (value) => { + const item = { + type: 'input_image', + image_url: normalizeOutputImageUrl(value), + }; + ensureContentItems(context).push(item); + return item; + }; + + return new SyntheticModule( + [ + 'load', + 'output_text', + 'output_image', + 'set_max_output_tokens_per_exec_call', + 'set_yield_time', + 'store', + 'background', + ], + function initCodeModeModule() { + this.setExport('load', load); + this.setExport('output_text', outputText); + this.setExport('output_image', outputImage); + this.setExport('set_max_output_tokens_per_exec_call', (value) => { + const normalized = normalizeMaxOutputTokensPerExecCall(value); + state.maxOutputTokensPerExecCall = normalized; + parentPort.postMessage({ + type: 'set_max_output_tokens_per_exec_call', + value: normalized, + }); + return normalized; + }); + this.setExport('set_yield_time', (value) => { + const normalized = normalizeYieldTime(value); + parentPort.postMessage({ + type: 'set_yield_time', + value: normalized, + }); + return normalized; + }); + this.setExport('store', store); + this.setExport('background', () => { + parentPort.postMessage({ type: 'yield' }); + }); + }, + { context } + ); + } + + function namespacesMatch(left, right) { + if (left.length !== right.length) { + return false; + } + return left.every((segment, index) => segment === right[index]); + } + + function createNamespacedToolsNamespace(callTool, enabledTools, namespace) { + const tools = Object.create(null); + + for (const tool of enabledTools) { + const toolNamespace = Array.isArray(tool.namespace) ? tool.namespace : []; + if (!namespacesMatch(toolNamespace, namespace)) { + continue; + } + + Object.defineProperty(tools, tool.name, { + value: async (args) => callTool(tool.tool_name, args), + configurable: false, + enumerable: true, + writable: false, + }); + } + + return Object.freeze(tools); + } + + function createNamespacedToolsModule(context, callTool, enabledTools, namespace) { + const tools = createNamespacedToolsNamespace(callTool, enabledTools, namespace); + const exportNames = []; + + for (const exportName of Object.keys(tools)) { + if (exportName !== 'ALL_TOOLS') { + exportNames.push(exportName); + } + } + + const uniqueExportNames = [...new Set(exportNames)]; + + return new SyntheticModule( + uniqueExportNames, + function initNamespacedToolsModule() { + for (const exportName of uniqueExportNames) { + this.setExport(exportName, tools[exportName]); + } + }, + { context } + ); + } + + function createModuleResolver(context, callTool, enabledTools, state) { + const toolsModule = createToolsModule(context, callTool, enabledTools); + const codeModeModule = createCodeModeModule(context, state); + const namespacedModules = new Map(); + + return function resolveModule(specifier) { + if (specifier === 'tools.js') { + return toolsModule; + } + if (specifier === '@openai/code_mode' || specifier === 'openai/code_mode') { + return codeModeModule; + } + const namespacedMatch = /^tools\/(.+)\.js$/.exec(specifier); + if (!namespacedMatch) { + throw new Error('Unsupported import in exec: ' + specifier); + } + + const namespace = namespacedMatch[1] + .split('/') + .filter((segment) => segment.length > 0); + if (namespace.length === 0) { + throw new Error('Unsupported import in exec: ' + specifier); + } + + const cacheKey = namespace.join('/'); + if (!namespacedModules.has(cacheKey)) { + namespacedModules.set( + cacheKey, + createNamespacedToolsModule(context, callTool, enabledTools, namespace) + ); + } + return namespacedModules.get(cacheKey); + }; + } + + async function runModule(context, start, state, callTool) { + const resolveModule = createModuleResolver( + context, + callTool, + start.enabled_tools ?? [], + state + ); + const mainModule = new SourceTextModule(start.source, { + context, + identifier: 'exec_main.mjs', + importModuleDynamically: async (specifier) => resolveModule(specifier), + }); + + await mainModule.link(resolveModule); + await mainModule.evaluate(); + } + + async function main() { + const start = workerData ?? {}; + const state = { + maxOutputTokensPerExecCall: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, + storedValues: cloneJsonValue(start.stored_values ?? {}), + }; + const callTool = createToolCaller(); + const context = vm.createContext({ + __codexContentItems: createContentItems(), + __codex_tool_call: callTool, + }); + + try { + await runModule(context, start, state, callTool); + parentPort.postMessage({ + type: 'result', + stored_values: state.storedValues, + }); + } catch (error) { + parentPort.postMessage({ + type: 'result', + stored_values: state.storedValues, + error_text: formatErrorText(error), + }); + } + } + + void main().catch((error) => { + parentPort.postMessage({ + type: 'result', + stored_values: {}, + error_text: formatErrorText(error), + }); + }); +} + +function createProtocol() { + const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, + }); + + let nextId = 0; + const pending = new Map(); + const sessions = new Map(); + let closedResolve; + const closed = new Promise((resolve) => { + closedResolve = resolve; + }); + + rl.on('line', (line) => { + if (!line.trim()) { + return; + } + + let message; + try { + message = JSON.parse(line); + } catch (error) { + process.stderr.write(formatErrorText(error) + '\n'); + return; + } + + if (message.type === 'start') { + startSession(protocol, sessions, message); + return; + } + + if (message.type === 'poll') { + const session = sessions.get(message.session_id); + if (session) { + session.request_id = String(message.request_id); + if (session.pending_result) { + void completeSession(protocol, sessions, session, session.pending_result); + } else { + schedulePollYield(protocol, session, normalizeYieldTime(message.yield_time_ms ?? 0)); + } + } else { + void protocol.send({ + type: 'result', + request_id: message.request_id, + content_items: [], + stored_values: {}, + error_text: `exec session ${message.session_id} not found`, + max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, + }); + } + return; + } + + if (message.type === 'terminate') { + const session = sessions.get(message.session_id); + if (session) { + session.request_id = String(message.request_id); + void terminateSession(protocol, sessions, session); + } else { + void protocol.send({ + type: 'result', + request_id: message.request_id, + content_items: [], + stored_values: {}, + error_text: `exec session ${message.session_id} not found`, + max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, + }); + } + return; + } + + if (message.type === 'response') { + const entry = pending.get(message.request_id + ':' + message.id); + if (!entry) { + return; + } + pending.delete(message.request_id + ':' + message.id); + entry.resolve(message.code_mode_result ?? ''); + return; + } + + process.stderr.write('Unknown protocol message type: ' + message.type + '\n'); + }); + + rl.on('close', () => { + const error = new Error('stdin closed'); + for (const entry of pending.values()) { + entry.reject(error); + } + pending.clear(); + for (const session of sessions.values()) { + session.initial_yield_timer = clearTimer(session.initial_yield_timer); + session.poll_yield_timer = clearTimer(session.poll_yield_timer); + void session.worker.terminate().catch(() => {}); + } + sessions.clear(); + closedResolve(); + }); + + function send(message) { + return new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(message) + '\n', (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + function request(type, payload) { + const requestId = 'req-' + ++nextId; + const id = 'msg-' + ++nextId; + const pendingKey = requestId + ':' + id; + return new Promise((resolve, reject) => { + pending.set(pendingKey, { resolve, reject }); + void send({ type, request_id: requestId, id, ...payload }).catch((error) => { + pending.delete(pendingKey); + reject(error); + }); + }); + } + + const protocol = { closed, request, send }; + return protocol; +} + +function sessionWorkerSource() { + return '(' + codeModeWorkerMain.toString() + ')();'; +} + +function startSession(protocol, sessions, start) { + const session = { + completed: false, + content_items: [], + id: start.session_id, + initial_yield_timer: null, + initial_yield_triggered: false, + max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, + pending_result: null, + poll_yield_timer: null, + request_id: String(start.request_id), + worker: new Worker(sessionWorkerSource(), { + eval: true, + workerData: start, + }), + }; + sessions.set(session.id, session); + + session.worker.on('message', (message) => { + void handleWorkerMessage(protocol, sessions, session, message).catch((error) => { + void completeSession(protocol, sessions, session, { + type: 'result', + stored_values: {}, + error_text: formatErrorText(error), + }); + }); + }); + session.worker.on('error', (error) => { + void completeSession(protocol, sessions, session, { + type: 'result', + stored_values: {}, + error_text: formatErrorText(error), + }); + }); + session.worker.on('exit', (code) => { + if (code !== 0 && !session.completed) { + void completeSession(protocol, sessions, session, { + type: 'result', + stored_values: {}, + error_text: 'exec worker exited with code ' + code, + }); + } + }); +} + +async function handleWorkerMessage(protocol, sessions, session, message) { + if (session.completed) { + return; + } + + if (message.type === 'content_item') { + session.content_items.push(cloneJsonValue(message.item)); + return; + } + + if (message.type === 'set_yield_time') { + scheduleInitialYield(protocol, session, normalizeYieldTime(message.value ?? 0)); + return; + } + + if (message.type === 'set_max_output_tokens_per_exec_call') { + session.max_output_tokens_per_exec_call = normalizeMaxOutputTokensPerExecCall(message.value); + return; + } + + if (message.type === 'yield') { + void sendYielded(protocol, session); + return; + } + + if (message.type === 'tool_call') { + void forwardToolCall(protocol, session, message); + return; + } + + if (message.type === 'result') { + const result = { + type: 'result', + stored_values: cloneJsonValue(message.stored_values ?? {}), + error_text: + typeof message.error_text === 'string' ? message.error_text : undefined, + }; + if (session.request_id === null) { + session.pending_result = result; + session.initial_yield_timer = clearTimer(session.initial_yield_timer); + session.poll_yield_timer = clearTimer(session.poll_yield_timer); + return; + } + await completeSession(protocol, sessions, session, result); + return; + } + + process.stderr.write('Unknown worker message type: ' + message.type + '\n'); +} + +async function forwardToolCall(protocol, session, message) { + try { + const result = await protocol.request('tool_call', { + name: String(message.name), + input: message.input, + }); + if (session.completed) { + return; + } + try { + session.worker.postMessage({ + type: 'tool_response', + id: message.id, + result, + }); + } catch {} + } catch (error) { + if (session.completed) { + return; + } + try { + session.worker.postMessage({ + type: 'tool_response_error', + id: message.id, + error_text: formatErrorText(error), + }); + } catch {} + } +} + +async function sendYielded(protocol, session) { + if (session.completed || session.request_id === null) { + return; + } + const contentItems = takeContentItems(session); + const requestId = session.request_id; + try { + session.worker.postMessage({ type: 'clear_content' }); + } catch {} + await protocol.send({ + type: 'yielded', + request_id: requestId, + content_items: contentItems, + }); + session.request_id = null; +} + +function scheduleInitialYield(protocol, session, yieldTime) { + if (session.completed || session.initial_yield_triggered) { + return yieldTime; + } + session.initial_yield_timer = clearTimer(session.initial_yield_timer); + session.initial_yield_timer = setTimeout(() => { + session.initial_yield_timer = null; + session.initial_yield_triggered = true; + void sendYielded(protocol, session); + }, yieldTime); + return yieldTime; +} + +function schedulePollYield(protocol, session, yieldTime) { + if (session.completed) { + return; + } + session.poll_yield_timer = clearTimer(session.poll_yield_timer); + session.poll_yield_timer = setTimeout(() => { + session.poll_yield_timer = null; + void sendYielded(protocol, session); + }, yieldTime); +} + +async function completeSession(protocol, sessions, session, message) { + if (session.completed) { + return; + } + if (session.request_id === null) { + session.pending_result = message; + session.initial_yield_timer = clearTimer(session.initial_yield_timer); + session.poll_yield_timer = clearTimer(session.poll_yield_timer); + return; + } + const requestId = session.request_id; + session.completed = true; + session.initial_yield_timer = clearTimer(session.initial_yield_timer); + session.poll_yield_timer = clearTimer(session.poll_yield_timer); + sessions.delete(session.id); + const contentItems = takeContentItems(session); + session.pending_result = null; + try { + session.worker.postMessage({ type: 'clear_content' }); + } catch {} + await protocol.send({ + ...message, + request_id: requestId, + content_items: contentItems, + max_output_tokens_per_exec_call: session.max_output_tokens_per_exec_call, + }); +} + +async function terminateSession(protocol, sessions, session) { + if (session.completed) { + return; + } + session.completed = true; + session.initial_yield_timer = clearTimer(session.initial_yield_timer); + session.poll_yield_timer = clearTimer(session.poll_yield_timer); + sessions.delete(session.id); + const contentItems = takeContentItems(session); + try { + await session.worker.terminate(); + } catch {} + await protocol.send({ + type: 'terminated', + request_id: session.request_id, + content_items: contentItems, + }); +} + +async function main() { + const protocol = createProtocol(); + await protocol.closed; +} + +void main().catch(async (error) => { + try { + process.stderr.write(formatErrorText(error) + '\n'); + } finally { + process.exitCode = 1; + } +}); diff --git a/codex-rs/core/src/tools/code_mode/service.rs b/codex-rs/core/src/tools/code_mode/service.rs new file mode 100644 index 0000000000..c7ca3c372d --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/service.rs @@ -0,0 +1,104 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use serde_json::Value as JsonValue; +use tokio::sync::Mutex; +use tracing::warn; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::features::Feature; +use crate::tools::context::SharedTurnDiffTracker; +use crate::tools::js_repl::resolve_compatible_node; + +use super::ExecContext; +use super::PUBLIC_TOOL_NAME; +use super::process::CodeModeProcess; +use super::process::spawn_code_mode_process; +use super::worker::CodeModeWorker; + +pub(crate) struct CodeModeService { + js_repl_node_path: Option, + stored_values: Mutex>, + process: Arc>>, + next_session_id: Mutex, +} + +impl CodeModeService { + pub(crate) fn new(js_repl_node_path: Option) -> Self { + Self { + js_repl_node_path, + stored_values: Mutex::new(HashMap::new()), + process: Arc::new(Mutex::new(None)), + next_session_id: Mutex::new(1), + } + } + + pub(crate) async fn stored_values(&self) -> HashMap { + self.stored_values.lock().await.clone() + } + + pub(crate) async fn replace_stored_values(&self, values: HashMap) { + *self.stored_values.lock().await = values; + } + + pub(super) async fn ensure_started( + &self, + ) -> Result>, std::io::Error> { + let mut process_slot = self.process.lock().await; + let needs_spawn = match process_slot.as_mut() { + Some(process) => !matches!(process.has_exited(), Ok(false)), + None => true, + }; + if needs_spawn { + let node_path = resolve_compatible_node(self.js_repl_node_path.as_deref()) + .await + .map_err(std::io::Error::other)?; + *process_slot = Some(spawn_code_mode_process(&node_path).await?); + } + drop(process_slot); + Ok(self.process.clone().lock_owned().await) + } + + pub(crate) async fn start_turn_worker( + &self, + session: &Arc, + turn: &Arc, + tracker: &SharedTurnDiffTracker, + ) -> Option { + if !turn.features.enabled(Feature::CodeMode) { + return None; + } + let exec = ExecContext { + session: Arc::clone(session), + turn: Arc::clone(turn), + tracker: Arc::clone(tracker), + }; + let mut process_slot = match self.ensure_started().await { + Ok(process_slot) => process_slot, + Err(err) => { + warn!("failed to start {PUBLIC_TOOL_NAME} worker for turn: {err}"); + return None; + } + }; + let Some(process) = process_slot.as_mut() else { + warn!( + "failed to start {PUBLIC_TOOL_NAME} worker for turn: {PUBLIC_TOOL_NAME} runner failed to start" + ); + return None; + }; + Some(process.worker(exec)) + } + + pub(crate) async fn allocate_session_id(&self) -> i32 { + let mut next_session_id = self.next_session_id.lock().await; + let session_id = *next_session_id; + *next_session_id = next_session_id.saturating_add(1); + session_id + } + + pub(crate) async fn allocate_request_id(&self) -> String { + uuid::Uuid::new_v4().to_string() + } +} diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs new file mode 100644 index 0000000000..ddfce8eb31 --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -0,0 +1,137 @@ +use async_trait::async_trait; +use serde::Deserialize; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +use super::CodeModeSessionProgress; +use super::DEFAULT_WAIT_YIELD_TIME_MS; +use super::ExecContext; +use super::PUBLIC_TOOL_NAME; +use super::WAIT_TOOL_NAME; +use super::handle_node_message; +use super::protocol::HostToNodeMessage; + +pub struct CodeModeWaitHandler; + +#[derive(Debug, Deserialize)] +struct ExecWaitArgs { + session_id: i32, + #[serde(default = "default_wait_yield_time_ms")] + yield_time_ms: u64, + #[serde(default)] + max_tokens: Option, + #[serde(default)] + terminate: bool, +} + +fn default_wait_yield_time_ms() -> u64 { + DEFAULT_WAIT_YIELD_TIME_MS +} + +fn parse_arguments(arguments: &str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_str(arguments).map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}")) + }) +} + +#[async_trait] +impl ToolHandler for CodeModeWaitHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tracker, + tool_name, + payload, + .. + } = invocation; + + match payload { + ToolPayload::Function { arguments } if tool_name == WAIT_TOOL_NAME => { + let args: ExecWaitArgs = parse_arguments(&arguments)?; + let exec = ExecContext { + session, + turn, + tracker, + }; + let request_id = exec + .session + .services + .code_mode_service + .allocate_request_id() + .await; + let started_at = std::time::Instant::now(); + let message = if args.terminate { + HostToNodeMessage::Terminate { + request_id: request_id.clone(), + session_id: args.session_id, + } + } else { + HostToNodeMessage::Poll { + request_id: request_id.clone(), + session_id: args.session_id, + yield_time_ms: args.yield_time_ms, + } + }; + let process_slot = exec + .session + .services + .code_mode_service + .ensure_started() + .await + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; + let result = { + let mut process_slot = process_slot; + let Some(process) = process_slot.as_mut() else { + return Err(FunctionCallError::RespondToModel(format!( + "{PUBLIC_TOOL_NAME} runner failed to start" + ))); + }; + if !matches!(process.has_exited(), Ok(false)) { + return Err(FunctionCallError::RespondToModel(format!( + "{PUBLIC_TOOL_NAME} runner failed to start" + ))); + } + let message = process + .send(&request_id, &message) + .await + .map_err(|err| err.to_string()); + let message = match message { + Ok(message) => message, + Err(error) => return Err(FunctionCallError::RespondToModel(error)), + }; + handle_node_message( + &exec, + args.session_id, + message, + Some(args.max_tokens), + started_at, + ) + .await + }; + match result { + Ok(CodeModeSessionProgress::Finished(output)) + | Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output), + Err(error) => Err(FunctionCallError::RespondToModel(error)), + } + } + _ => Err(FunctionCallError::RespondToModel(format!( + "{WAIT_TOOL_NAME} expects JSON arguments" + ))), + } + } +} diff --git a/codex-rs/core/src/tools/code_mode/worker.rs b/codex-rs/core/src/tools/code_mode/worker.rs new file mode 100644 index 0000000000..ce739d637c --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/worker.rs @@ -0,0 +1,59 @@ +use tokio::sync::oneshot; +use tracing::warn; + +use super::ExecContext; +use super::PUBLIC_TOOL_NAME; +use super::call_nested_tool; +use super::process::CodeModeProcess; +use super::process::write_message; +use super::protocol::HostToNodeMessage; +pub(crate) struct CodeModeWorker { + shutdown_tx: Option>, +} + +impl Drop for CodeModeWorker { + fn drop(&mut self) { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + } +} + +impl CodeModeProcess { + pub(super) fn worker(&self, exec: ExecContext) -> CodeModeWorker { + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + let stdin = self.stdin.clone(); + let tool_call_rx = self.tool_call_rx.clone(); + tokio::spawn(async move { + loop { + let tool_call = tokio::select! { + _ = &mut shutdown_rx => break, + tool_call = async { + let mut tool_call_rx = tool_call_rx.lock().await; + tool_call_rx.recv().await + } => tool_call, + }; + let Some(tool_call) = tool_call else { + break; + }; + let exec = exec.clone(); + let stdin = stdin.clone(); + tokio::spawn(async move { + let response = HostToNodeMessage::Response { + request_id: tool_call.request_id, + id: tool_call.id, + code_mode_result: call_nested_tool(exec, tool_call.name, tool_call.input) + .await, + }; + if let Err(err) = write_message(&stdin, &response).await { + warn!("failed to write {PUBLIC_TOOL_NAME} tool response: {err}"); + } + }); + } + }); + + CodeModeWorker { + shutdown_tx: Some(shutdown_tx), + } + } +} diff --git a/codex-rs/core/src/tools/code_mode_description.rs b/codex-rs/core/src/tools/code_mode_description.rs index 2a3ba815cc..8ed9fc6f53 100644 --- a/codex-rs/core/src/tools/code_mode_description.rs +++ b/codex-rs/core/src/tools/code_mode_description.rs @@ -291,80 +291,5 @@ fn render_json_schema_literal(value: &JsonValue) -> String { } #[cfg(test)] -mod tests { - use super::render_json_schema_to_typescript; - use pretty_assertions::assert_eq; - use serde_json::json; - - #[test] - fn render_json_schema_to_typescript_renders_object_properties() { - let schema = json!({ - "type": "object", - "properties": { - "path": {"type": "string"}, - "recursive": {"type": "boolean"} - }, - "required": ["path"], - "additionalProperties": false - }); - - assert_eq!( - render_json_schema_to_typescript(&schema), - "{\n path: string;\n recursive?: boolean;\n}" - ); - } - - #[test] - fn render_json_schema_to_typescript_renders_anyof_unions() { - let schema = json!({ - "anyOf": [ - {"const": "pending"}, - {"const": "done"}, - {"type": "number"} - ] - }); - - assert_eq!( - render_json_schema_to_typescript(&schema), - "\"pending\" | \"done\" | number" - ); - } - - #[test] - fn render_json_schema_to_typescript_renders_additional_properties() { - let schema = json!({ - "type": "object", - "properties": { - "tags": { - "type": "array", - "items": {"type": "string"} - } - }, - "additionalProperties": {"type": "integer"} - }); - - assert_eq!( - render_json_schema_to_typescript(&schema), - "{\n tags?: Array;\n [key: string]: number;\n}" - ); - } - - #[test] - fn render_json_schema_to_typescript_sorts_object_properties() { - let schema = json!({ - "type": "object", - "properties": { - "structuredContent": {"type": "string"}, - "_meta": {"type": "string"}, - "isError": {"type": "boolean"}, - "content": {"type": "array", "items": {"type": "string"}} - }, - "required": ["content"] - }); - - assert_eq!( - render_json_schema_to_typescript(&schema), - "{\n _meta?: string;\n content: Array;\n isError?: boolean;\n structuredContent?: string;\n}" - ); - } -} +#[path = "code_mode_description_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/code_mode_description_tests.rs b/codex-rs/core/src/tools/code_mode_description_tests.rs new file mode 100644 index 0000000000..500d7bf670 --- /dev/null +++ b/codex-rs/core/src/tools/code_mode_description_tests.rs @@ -0,0 +1,75 @@ +use super::render_json_schema_to_typescript; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn render_json_schema_to_typescript_renders_object_properties() { + let schema = json!({ + "type": "object", + "properties": { + "path": {"type": "string"}, + "recursive": {"type": "boolean"} + }, + "required": ["path"], + "additionalProperties": false + }); + + assert_eq!( + render_json_schema_to_typescript(&schema), + "{\n path: string;\n recursive?: boolean;\n}" + ); +} + +#[test] +fn render_json_schema_to_typescript_renders_anyof_unions() { + let schema = json!({ + "anyOf": [ + {"const": "pending"}, + {"const": "done"}, + {"type": "number"} + ] + }); + + assert_eq!( + render_json_schema_to_typescript(&schema), + "\"pending\" | \"done\" | number" + ); +} + +#[test] +fn render_json_schema_to_typescript_renders_additional_properties() { + let schema = json!({ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"} + } + }, + "additionalProperties": {"type": "integer"} + }); + + assert_eq!( + render_json_schema_to_typescript(&schema), + "{\n tags?: Array;\n [key: string]: number;\n}" + ); +} + +#[test] +fn render_json_schema_to_typescript_sorts_object_properties() { + let schema = json!({ + "type": "object", + "properties": { + "structuredContent": {"type": "string"}, + "_meta": {"type": "string"}, + "isError": {"type": "boolean"}, + "content": {"type": "array", "items": {"type": "string"}} + }, + "required": ["content"] + }); + + assert_eq!( + render_json_schema_to_typescript(&schema), + "{\n _meta?: string;\n content: Array;\n isError?: boolean;\n structuredContent?: string;\n}" + ); +} diff --git a/codex-rs/core/src/tools/code_mode_runner.cjs b/codex-rs/core/src/tools/code_mode_runner.cjs deleted file mode 100644 index f36fa6f92e..0000000000 --- a/codex-rs/core/src/tools/code_mode_runner.cjs +++ /dev/null @@ -1,408 +0,0 @@ -'use strict'; - -const readline = require('node:readline'); -const vm = require('node:vm'); - -const { SourceTextModule, SyntheticModule } = vm; -const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL = 10000; - -function normalizeMaxOutputTokensPerExecCall(value) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError('max_output_tokens_per_exec_call must be a non-negative safe integer'); - } - return value; -} - -function createProtocol() { - const rl = readline.createInterface({ - input: process.stdin, - crlfDelay: Infinity, - }); - - let nextId = 0; - const pending = new Map(); - let initResolve; - let initReject; - const init = new Promise((resolve, reject) => { - initResolve = resolve; - initReject = reject; - }); - - rl.on('line', (line) => { - if (!line.trim()) { - return; - } - - let message; - try { - message = JSON.parse(line); - } catch (error) { - initReject(error); - return; - } - - if (message.type === 'init') { - initResolve(message); - return; - } - - if (message.type === 'response') { - const entry = pending.get(message.id); - if (!entry) { - return; - } - pending.delete(message.id); - entry.resolve(message.code_mode_result ?? ''); - return; - } - - initReject(new Error(`Unknown protocol message type: ${message.type}`)); - }); - - rl.on('close', () => { - const error = new Error('stdin closed'); - initReject(error); - for (const entry of pending.values()) { - entry.reject(error); - } - pending.clear(); - }); - - function send(message) { - return new Promise((resolve, reject) => { - process.stdout.write(`${JSON.stringify(message)}\n`, (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - } - - function request(type, payload) { - const id = `msg-${++nextId}`; - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); - void send({ type, id, ...payload }).catch((error) => { - pending.delete(id); - reject(error); - }); - }); - } - - return { init, request, send }; -} - -function readContentItems(context) { - try { - const serialized = vm.runInContext('JSON.stringify(globalThis.__codexContentItems ?? [])', context); - const contentItems = JSON.parse(serialized); - return Array.isArray(contentItems) ? contentItems : []; - } catch { - return []; - } -} - -function formatErrorText(error) { - return String(error && error.stack ? error.stack : error); -} - -function cloneJsonValue(value) { - return JSON.parse(JSON.stringify(value)); -} - -function createToolCaller(protocol) { - return (name, input) => - protocol.request('tool_call', { - name: String(name), - input, - }); -} - -function createToolsNamespace(callTool, enabledTools) { - const tools = Object.create(null); - - for (const { tool_name } of enabledTools) { - Object.defineProperty(tools, tool_name, { - value: async (args) => callTool(tool_name, args), - configurable: false, - enumerable: true, - writable: false, - }); - } - - return Object.freeze(tools); -} - -function createAllToolsMetadata(enabledTools) { - return Object.freeze( - enabledTools.map(({ module: modulePath, name, description }) => - Object.freeze({ - module: modulePath, - name, - description, - }) - ) - ); -} - -function createToolsModule(context, callTool, enabledTools) { - const tools = createToolsNamespace(callTool, enabledTools); - const allTools = createAllToolsMetadata(enabledTools); - const exportNames = ['ALL_TOOLS']; - - for (const { tool_name } of enabledTools) { - if (tool_name !== 'ALL_TOOLS') { - exportNames.push(tool_name); - } - } - - const uniqueExportNames = [...new Set(exportNames)]; - - return new SyntheticModule( - uniqueExportNames, - function initToolsModule() { - this.setExport('ALL_TOOLS', allTools); - for (const exportName of uniqueExportNames) { - if (exportName !== 'ALL_TOOLS') { - this.setExport(exportName, tools[exportName]); - } - } - }, - { context } - ); -} - -function ensureContentItems(context) { - if (!Array.isArray(context.__codexContentItems)) { - context.__codexContentItems = []; - } - return context.__codexContentItems; -} - -function serializeOutputText(value) { - if (typeof value === 'string') { - return value; - } - if ( - typeof value === 'undefined' || - value === null || - typeof value === 'boolean' || - typeof value === 'number' || - typeof value === 'bigint' - ) { - return String(value); - } - - const serialized = JSON.stringify(value); - if (typeof serialized === 'string') { - return serialized; - } - - return String(value); -} - -function normalizeOutputImageUrl(value) { - if (typeof value !== 'string' || !value) { - throw new TypeError('output_image expects a non-empty image URL string'); - } - if (/^(?:https?:\/\/|data:)/i.test(value)) { - return value; - } - throw new TypeError('output_image expects an http(s) or data URL'); -} - -function createCodeModeModule(context, state) { - const load = (key) => { - if (typeof key !== 'string') { - throw new TypeError('load key must be a string'); - } - if (!Object.prototype.hasOwnProperty.call(state.storedValues, key)) { - return undefined; - } - return cloneJsonValue(state.storedValues[key]); - }; - const store = (key, value) => { - if (typeof key !== 'string') { - throw new TypeError('store key must be a string'); - } - state.storedValues[key] = cloneJsonValue(value); - }; - const outputText = (value) => { - const item = { - type: 'input_text', - text: serializeOutputText(value), - }; - ensureContentItems(context).push(item); - return item; - }; - const outputImage = (value) => { - const item = { - type: 'input_image', - image_url: normalizeOutputImageUrl(value), - }; - ensureContentItems(context).push(item); - return item; - }; - - return new SyntheticModule( - ['load', 'output_text', 'output_image', 'set_max_output_tokens_per_exec_call', 'store'], - function initCodeModeModule() { - this.setExport('load', load); - this.setExport('output_text', outputText); - this.setExport('output_image', outputImage); - this.setExport('set_max_output_tokens_per_exec_call', (value) => { - const normalized = normalizeMaxOutputTokensPerExecCall(value); - state.maxOutputTokensPerExecCall = normalized; - return normalized; - }); - this.setExport('store', store); - }, - { context } - ); -} - -function namespacesMatch(left, right) { - if (left.length !== right.length) { - return false; - } - return left.every((segment, index) => segment === right[index]); -} - -function createNamespacedToolsNamespace(callTool, enabledTools, namespace) { - const tools = Object.create(null); - - for (const tool of enabledTools) { - const toolNamespace = Array.isArray(tool.namespace) ? tool.namespace : []; - if (!namespacesMatch(toolNamespace, namespace)) { - continue; - } - - Object.defineProperty(tools, tool.name, { - value: async (args) => callTool(tool.tool_name, args), - configurable: false, - enumerable: true, - writable: false, - }); - } - - return Object.freeze(tools); -} - -function createNamespacedToolsModule(context, callTool, enabledTools, namespace) { - const tools = createNamespacedToolsNamespace(callTool, enabledTools, namespace); - const exportNames = []; - - for (const exportName of Object.keys(tools)) { - if (exportName !== 'ALL_TOOLS') { - exportNames.push(exportName); - } - } - - const uniqueExportNames = [...new Set(exportNames)]; - - return new SyntheticModule( - uniqueExportNames, - function initNamespacedToolsModule() { - for (const exportName of uniqueExportNames) { - this.setExport(exportName, tools[exportName]); - } - }, - { context } - ); -} - -function createModuleResolver(context, callTool, enabledTools, state) { - const toolsModule = createToolsModule(context, callTool, enabledTools); - const codeModeModule = createCodeModeModule(context, state); - const namespacedModules = new Map(); - - return function resolveModule(specifier) { - if (specifier === 'tools.js') { - return toolsModule; - } - if (specifier === '@openai/code_mode' || specifier === 'openai/code_mode') { - return codeModeModule; - } - const namespacedMatch = /^tools\/(.+)\.js$/.exec(specifier); - if (!namespacedMatch) { - throw new Error(`Unsupported import in exec: ${specifier}`); - } - - const namespace = namespacedMatch[1] - .split('/') - .filter((segment) => segment.length > 0); - if (namespace.length === 0) { - throw new Error(`Unsupported import in exec: ${specifier}`); - } - - const cacheKey = namespace.join('/'); - if (!namespacedModules.has(cacheKey)) { - namespacedModules.set( - cacheKey, - createNamespacedToolsModule(context, callTool, enabledTools, namespace) - ); - } - return namespacedModules.get(cacheKey); - }; -} - -async function runModule(context, request, state, callTool) { - const resolveModule = createModuleResolver( - context, - callTool, - request.enabled_tools ?? [], - state - ); - const mainModule = new SourceTextModule(request.source, { - context, - identifier: 'exec_main.mjs', - importModuleDynamically: async (specifier) => resolveModule(specifier), - }); - - await mainModule.link(resolveModule); - await mainModule.evaluate(); -} - -async function main() { - const protocol = createProtocol(); - const request = await protocol.init; - const state = { - maxOutputTokensPerExecCall: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, - storedValues: cloneJsonValue(request.stored_values ?? {}), - }; - const callTool = createToolCaller(protocol); - const context = vm.createContext({ - __codexContentItems: [], - __codex_tool_call: callTool, - }); - - try { - await runModule(context, request, state, callTool); - await protocol.send({ - type: 'result', - content_items: readContentItems(context), - stored_values: state.storedValues, - max_output_tokens_per_exec_call: state.maxOutputTokensPerExecCall, - }); - process.exit(0); - } catch (error) { - await protocol.send({ - type: 'result', - content_items: readContentItems(context), - stored_values: state.storedValues, - error_text: formatErrorText(error), - max_output_tokens_per_exec_call: state.maxOutputTokensPerExecCall, - }); - process.exit(1); - } -} - -void main().catch(async (error) => { - try { - process.stderr.write(`${formatErrorText(error)}\n`); - } finally { - process.exitCode = 1; - } -}); diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 041de50f5e..ccb38623ba 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -1,3 +1,4 @@ +use crate::client_common::tools::ToolSearchOutputTool; use crate::codex::Session; use crate::codex::TurnContext; use crate::tools::TELEMETRY_PREVIEW_MAX_BYTES; @@ -12,6 +13,7 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::SearchToolCallParams; use codex_protocol::models::ShellToolCallParams; use codex_protocol::models::function_call_output_content_items_to_text; use codex_utils_string::take_bytes_at_char_boundary; @@ -38,6 +40,7 @@ pub struct ToolInvocation { pub tracker: SharedTurnDiffTracker, pub call_id: String, pub tool_name: String, + pub tool_namespace: Option, pub payload: ToolPayload, } @@ -46,6 +49,9 @@ pub enum ToolPayload { Function { arguments: String, }, + ToolSearch { + arguments: SearchToolCallParams, + }, Custom { input: String, }, @@ -63,6 +69,7 @@ impl ToolPayload { pub fn log_payload(&self) -> Cow<'_, str> { match self { ToolPayload::Function { arguments } => Cow::Borrowed(arguments), + ToolPayload::ToolSearch { arguments } => Cow::Owned(arguments.query.clone()), ToolPayload::Custom { input } => Cow::Borrowed(input), ToolPayload::LocalShell { params } => Cow::Owned(params.command.join(" ")), ToolPayload::Mcp { raw_arguments, .. } => Cow::Borrowed(raw_arguments), @@ -107,6 +114,47 @@ impl ToolOutput for CallToolResult { } } +#[derive(Clone)] +pub struct ToolSearchOutput { + pub tools: Vec, +} + +impl ToolOutput for ToolSearchOutput { + fn log_preview(&self) -> String { + let tools = self + .tools + .iter() + .map(|tool| { + serde_json::to_value(tool).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize tool_search output: {err}")) + }) + }) + .collect(); + telemetry_preview(&JsonValue::Array(tools).to_string()) + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { + ResponseInputItem::ToolSearchOutput { + call_id: call_id.to_string(), + status: "completed".to_string(), + execution: "client".to_string(), + tools: self + .tools + .iter() + .map(|tool| { + serde_json::to_value(tool).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize tool_search output: {err}")) + }) + }) + .collect(), + } + } +} + pub struct FunctionToolOutput { pub body: Vec, pub success: Option, @@ -277,6 +325,7 @@ fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue content_items_to_code_mode_result(&items) } }, + ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools), ResponseInputItem::McpToolCallOutput { output, .. } => { output.code_mode_result(&ToolPayload::Mcp { server: String::new(), @@ -375,223 +424,5 @@ fn telemetry_preview(content: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - use core_test_support::assert_regex_match; - use pretty_assertions::assert_eq; - - #[test] - fn custom_tool_calls_should_roundtrip_as_custom_outputs() { - let payload = ToolPayload::Custom { - input: "patch".to_string(), - }; - let response = FunctionToolOutput::from_text("patched".to_string(), Some(true)) - .to_response_item("call-42", &payload); - - match response { - ResponseInputItem::CustomToolCallOutput { call_id, output } => { - assert_eq!(call_id, "call-42"); - assert_eq!(output.content_items(), None); - assert_eq!(output.body.to_text().as_deref(), Some("patched")); - assert_eq!(output.success, Some(true)); - } - other => panic!("expected CustomToolCallOutput, got {other:?}"), - } - } - - #[test] - fn function_payloads_remain_function_outputs() { - let payload = ToolPayload::Function { - arguments: "{}".to_string(), - }; - let response = FunctionToolOutput::from_text("ok".to_string(), Some(true)) - .to_response_item("fn-1", &payload); - - match response { - ResponseInputItem::FunctionCallOutput { call_id, output } => { - assert_eq!(call_id, "fn-1"); - assert_eq!(output.content_items(), None); - assert_eq!(output.body.to_text().as_deref(), Some("ok")); - assert_eq!(output.success, Some(true)); - } - other => panic!("expected FunctionCallOutput, got {other:?}"), - } - } - - #[test] - fn mcp_code_mode_result_serializes_full_call_tool_result() { - let output = CallToolResult { - content: vec![serde_json::json!({ - "type": "text", - "text": "ignored", - })], - structured_content: Some(serde_json::json!({ - "threadId": "thread_123", - "content": "done", - })), - is_error: Some(false), - meta: Some(serde_json::json!({ - "source": "mcp", - })), - }; - - let result = output.code_mode_result(&ToolPayload::Mcp { - server: "server".to_string(), - tool: "tool".to_string(), - raw_arguments: "{}".to_string(), - }); - - assert_eq!( - result, - serde_json::json!({ - "content": [{ - "type": "text", - "text": "ignored", - }], - "structuredContent": { - "threadId": "thread_123", - "content": "done", - }, - "isError": false, - "_meta": { - "source": "mcp", - }, - }) - ); - } - - #[test] - fn custom_tool_calls_can_derive_text_from_content_items() { - let payload = ToolPayload::Custom { - input: "patch".to_string(), - }; - let response = FunctionToolOutput::from_content( - vec![ - FunctionCallOutputContentItem::InputText { - text: "line 1".to_string(), - }, - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: None, - }, - FunctionCallOutputContentItem::InputText { - text: "line 2".to_string(), - }, - ], - Some(true), - ) - .to_response_item("call-99", &payload); - - match response { - ResponseInputItem::CustomToolCallOutput { call_id, output } => { - let expected = vec![ - FunctionCallOutputContentItem::InputText { - text: "line 1".to_string(), - }, - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: None, - }, - FunctionCallOutputContentItem::InputText { - text: "line 2".to_string(), - }, - ]; - assert_eq!(call_id, "call-99"); - assert_eq!(output.content_items(), Some(expected.as_slice())); - assert_eq!(output.body.to_text().as_deref(), Some("line 1\nline 2")); - assert_eq!(output.success, Some(true)); - } - other => panic!("expected CustomToolCallOutput, got {other:?}"), - } - } - - #[test] - fn log_preview_uses_content_items_when_plain_text_is_missing() { - let output = FunctionToolOutput::from_content( - vec![FunctionCallOutputContentItem::InputText { - text: "preview".to_string(), - }], - Some(true), - ); - - assert_eq!(output.log_preview(), "preview"); - assert_eq!( - function_call_output_content_items_to_text(&output.body), - Some("preview".to_string()) - ); - } - - #[test] - fn telemetry_preview_returns_original_within_limits() { - let content = "short output"; - assert_eq!(telemetry_preview(content), content); - } - - #[test] - fn telemetry_preview_truncates_by_bytes() { - let content = "x".repeat(TELEMETRY_PREVIEW_MAX_BYTES + 8); - let preview = telemetry_preview(&content); - - assert!(preview.contains(TELEMETRY_PREVIEW_TRUNCATION_NOTICE)); - assert!( - preview.len() - <= TELEMETRY_PREVIEW_MAX_BYTES + TELEMETRY_PREVIEW_TRUNCATION_NOTICE.len() + 1 - ); - } - - #[test] - fn telemetry_preview_truncates_by_lines() { - let content = (0..(TELEMETRY_PREVIEW_MAX_LINES + 5)) - .map(|idx| format!("line {idx}")) - .collect::>() - .join("\n"); - - let preview = telemetry_preview(&content); - let lines: Vec<&str> = preview.lines().collect(); - - assert!(lines.len() <= TELEMETRY_PREVIEW_MAX_LINES + 1); - assert_eq!(lines.last(), Some(&TELEMETRY_PREVIEW_TRUNCATION_NOTICE)); - } - - #[test] - fn exec_command_tool_output_formats_truncated_response() { - let payload = ToolPayload::Function { - arguments: "{}".to_string(), - }; - let response = ExecCommandToolOutput { - event_call_id: "call-42".to_string(), - chunk_id: "abc123".to_string(), - wall_time: std::time::Duration::from_millis(1250), - raw_output: b"token one token two token three token four token five".to_vec(), - max_output_tokens: Some(4), - process_id: None, - exit_code: Some(0), - original_token_count: Some(10), - session_command: None, - } - .to_response_item("call-42", &payload); - - match response { - ResponseInputItem::FunctionCallOutput { call_id, output } => { - assert_eq!(call_id, "call-42"); - assert_eq!(output.success, Some(true)); - let text = output - .body - .to_text() - .expect("exec output should serialize as text"); - assert_regex_match( - r#"(?sx) - ^Chunk\ ID:\ abc123 - \nWall\ time:\ \d+\.\d{4}\ seconds - \nProcess\ exited\ with\ code\ 0 - \nOriginal\ token\ count:\ 10 - \nOutput: - \n.*tokens\ truncated.* - $"#, - &text, - ); - } - other => panic!("expected FunctionCallOutput, got {other:?}"), - } - } -} +#[path = "context_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs new file mode 100644 index 0000000000..d6ad76c5d5 --- /dev/null +++ b/codex-rs/core/src/tools/context_tests.rs @@ -0,0 +1,274 @@ +use super::*; +use core_test_support::assert_regex_match; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn custom_tool_calls_should_roundtrip_as_custom_outputs() { + let payload = ToolPayload::Custom { + input: "patch".to_string(), + }; + let response = FunctionToolOutput::from_text("patched".to_string(), Some(true)) + .to_response_item("call-42", &payload); + + match response { + ResponseInputItem::CustomToolCallOutput { call_id, output } => { + assert_eq!(call_id, "call-42"); + assert_eq!(output.content_items(), None); + assert_eq!(output.body.to_text().as_deref(), Some("patched")); + assert_eq!(output.success, Some(true)); + } + other => panic!("expected CustomToolCallOutput, got {other:?}"), + } +} + +#[test] +fn function_payloads_remain_function_outputs() { + let payload = ToolPayload::Function { + arguments: "{}".to_string(), + }; + let response = FunctionToolOutput::from_text("ok".to_string(), Some(true)) + .to_response_item("fn-1", &payload); + + match response { + ResponseInputItem::FunctionCallOutput { call_id, output } => { + assert_eq!(call_id, "fn-1"); + assert_eq!(output.content_items(), None); + assert_eq!(output.body.to_text().as_deref(), Some("ok")); + assert_eq!(output.success, Some(true)); + } + other => panic!("expected FunctionCallOutput, got {other:?}"), + } +} + +#[test] +fn mcp_code_mode_result_serializes_full_call_tool_result() { + let output = CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "ignored", + })], + structured_content: Some(serde_json::json!({ + "threadId": "thread_123", + "content": "done", + })), + is_error: Some(false), + meta: Some(serde_json::json!({ + "source": "mcp", + })), + }; + + let result = output.code_mode_result(&ToolPayload::Mcp { + server: "server".to_string(), + tool: "tool".to_string(), + raw_arguments: "{}".to_string(), + }); + + assert_eq!( + result, + serde_json::json!({ + "content": [{ + "type": "text", + "text": "ignored", + }], + "structuredContent": { + "threadId": "thread_123", + "content": "done", + }, + "isError": false, + "_meta": { + "source": "mcp", + }, + }) + ); +} + +#[test] +fn custom_tool_calls_can_derive_text_from_content_items() { + let payload = ToolPayload::Custom { + input: "patch".to_string(), + }; + let response = FunctionToolOutput::from_content( + vec![ + FunctionCallOutputContentItem::InputText { + text: "line 1".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: None, + }, + FunctionCallOutputContentItem::InputText { + text: "line 2".to_string(), + }, + ], + Some(true), + ) + .to_response_item("call-99", &payload); + + match response { + ResponseInputItem::CustomToolCallOutput { call_id, output } => { + let expected = vec![ + FunctionCallOutputContentItem::InputText { + text: "line 1".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: None, + }, + FunctionCallOutputContentItem::InputText { + text: "line 2".to_string(), + }, + ]; + assert_eq!(call_id, "call-99"); + assert_eq!(output.content_items(), Some(expected.as_slice())); + assert_eq!(output.body.to_text().as_deref(), Some("line 1\nline 2")); + assert_eq!(output.success, Some(true)); + } + other => panic!("expected CustomToolCallOutput, got {other:?}"), + } +} + +#[test] +fn tool_search_payloads_roundtrip_as_tool_search_outputs() { + let payload = ToolPayload::ToolSearch { + arguments: SearchToolCallParams { + query: "calendar".to_string(), + limit: None, + }, + }; + let response = ToolSearchOutput { + tools: vec![ToolSearchOutputTool::Function( + crate::client_common::tools::ResponsesApiTool { + name: "create_event".to_string(), + description: String::new(), + strict: false, + defer_loading: Some(true), + parameters: crate::tools::spec::JsonSchema::Object { + properties: Default::default(), + required: None, + additional_properties: None, + }, + output_schema: None, + }, + )], + } + .to_response_item("search-1", &payload); + + match response { + ResponseInputItem::ToolSearchOutput { + call_id, + status, + execution, + tools, + } => { + assert_eq!(call_id, "search-1"); + assert_eq!(status, "completed"); + assert_eq!(execution, "client"); + assert_eq!( + tools, + vec![json!({ + "type": "function", + "name": "create_event", + "description": "", + "strict": false, + "defer_loading": true, + "parameters": { + "type": "object", + "properties": {} + } + })] + ); + } + other => panic!("expected ToolSearchOutput, got {other:?}"), + } +} + +#[test] +fn log_preview_uses_content_items_when_plain_text_is_missing() { + let output = FunctionToolOutput::from_content( + vec![FunctionCallOutputContentItem::InputText { + text: "preview".to_string(), + }], + Some(true), + ); + + assert_eq!(output.log_preview(), "preview"); + assert_eq!( + function_call_output_content_items_to_text(&output.body), + Some("preview".to_string()) + ); +} + +#[test] +fn telemetry_preview_returns_original_within_limits() { + let content = "short output"; + assert_eq!(telemetry_preview(content), content); +} + +#[test] +fn telemetry_preview_truncates_by_bytes() { + let content = "x".repeat(TELEMETRY_PREVIEW_MAX_BYTES + 8); + let preview = telemetry_preview(&content); + + assert!(preview.contains(TELEMETRY_PREVIEW_TRUNCATION_NOTICE)); + assert!( + preview.len() + <= TELEMETRY_PREVIEW_MAX_BYTES + TELEMETRY_PREVIEW_TRUNCATION_NOTICE.len() + 1 + ); +} + +#[test] +fn telemetry_preview_truncates_by_lines() { + let content = (0..(TELEMETRY_PREVIEW_MAX_LINES + 5)) + .map(|idx| format!("line {idx}")) + .collect::>() + .join("\n"); + + let preview = telemetry_preview(&content); + let lines: Vec<&str> = preview.lines().collect(); + + assert!(lines.len() <= TELEMETRY_PREVIEW_MAX_LINES + 1); + assert_eq!(lines.last(), Some(&TELEMETRY_PREVIEW_TRUNCATION_NOTICE)); +} + +#[test] +fn exec_command_tool_output_formats_truncated_response() { + let payload = ToolPayload::Function { + arguments: "{}".to_string(), + }; + let response = ExecCommandToolOutput { + event_call_id: "call-42".to_string(), + chunk_id: "abc123".to_string(), + wall_time: std::time::Duration::from_millis(1250), + raw_output: b"token one token two token three token four token five".to_vec(), + max_output_tokens: Some(4), + process_id: None, + exit_code: Some(0), + original_token_count: Some(10), + session_command: None, + } + .to_response_item("call-42", &payload); + + match response { + ResponseInputItem::FunctionCallOutput { call_id, output } => { + assert_eq!(call_id, "call-42"); + assert_eq!(output.success, Some(true)); + let text = output + .body + .to_text() + .expect("exec output should serialize as text"); + assert_regex_match( + r#"(?sx) + ^Chunk\ ID:\ abc123 + \nWall\ time:\ \d+\.\d{4}\ seconds + \nProcess\ exited\ with\ code\ 0 + \nOriginal\ token\ count:\ 10 + \nOutput: + \n.*tokens\ truncated.* + $"#, + &text, + ); + } + other => panic!("expected FunctionCallOutput, got {other:?}"), + } +} diff --git a/codex-rs/core/src/tools/discoverable.rs b/codex-rs/core/src/tools/discoverable.rs new file mode 100644 index 0000000000..75de51b150 --- /dev/null +++ b/codex-rs/core/src/tools/discoverable.rs @@ -0,0 +1,111 @@ +use crate::plugins::PluginCapabilitySummary; +use codex_app_server_protocol::AppInfo; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum DiscoverableToolType { + Connector, + Plugin, +} + +impl DiscoverableToolType { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Connector => "connector", + Self::Plugin => "plugin", + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum DiscoverableToolAction { + Install, + Enable, +} + +impl DiscoverableToolAction { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Install => "install", + Self::Enable => "enable", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum DiscoverableTool { + Connector(Box), + Plugin(Box), +} + +impl DiscoverableTool { + pub(crate) fn tool_type(&self) -> DiscoverableToolType { + match self { + Self::Connector(_) => DiscoverableToolType::Connector, + Self::Plugin(_) => DiscoverableToolType::Plugin, + } + } + + pub(crate) fn id(&self) -> &str { + match self { + Self::Connector(connector) => connector.id.as_str(), + Self::Plugin(plugin) => plugin.id.as_str(), + } + } + + pub(crate) fn name(&self) -> &str { + match self { + Self::Connector(connector) => connector.name.as_str(), + Self::Plugin(plugin) => plugin.name.as_str(), + } + } + + pub(crate) fn description(&self) -> Option<&str> { + match self { + Self::Connector(connector) => connector.description.as_deref(), + Self::Plugin(plugin) => plugin.description.as_deref(), + } + } +} + +impl From for DiscoverableTool { + fn from(value: AppInfo) -> Self { + Self::Connector(Box::new(value)) + } +} + +impl From for DiscoverableTool { + fn from(value: DiscoverablePluginInfo) -> Self { + Self::Plugin(Box::new(value)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DiscoverablePluginInfo { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) description: Option, + pub(crate) has_skills: bool, + pub(crate) mcp_server_names: Vec, + pub(crate) app_connector_ids: Vec, +} + +impl From for DiscoverablePluginInfo { + fn from(value: PluginCapabilitySummary) -> Self { + Self { + id: value.config_name, + name: value.display_name, + description: value.description, + has_skills: value.has_skills, + mcp_server_names: value.mcp_server_names, + app_connector_ids: value + .app_connector_ids + .into_iter() + .map(|connector_id| connector_id.0) + .collect(), + } + } +} diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index ff02a3fbd1..4e786178f8 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -1152,67 +1152,5 @@ fn csv_escape(value: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use serde_json::json; - - #[test] - fn parse_csv_supports_quotes_and_commas() { - let input = "id,name\n1,\"alpha, beta\"\n2,gamma\n"; - let (headers, rows) = parse_csv(input).expect("csv parse"); - assert_eq!(headers, vec!["id".to_string(), "name".to_string()]); - assert_eq!( - rows, - vec![ - vec!["1".to_string(), "alpha, beta".to_string()], - vec!["2".to_string(), "gamma".to_string()] - ] - ); - } - - #[test] - fn csv_escape_quotes_when_needed() { - assert_eq!(csv_escape("simple"), "simple"); - assert_eq!(csv_escape("a,b"), "\"a,b\""); - assert_eq!(csv_escape("a\"b"), "\"a\"\"b\""); - } - - #[test] - fn render_instruction_template_expands_placeholders_and_escapes_braces() { - let row = json!({ - "path": "src/lib.rs", - "area": "test", - "file path": "docs/readme.md", - }); - let rendered = render_instruction_template( - "Review {path} in {area}. Also see {file path}. Use {{literal}}.", - &row, - ); - assert_eq!( - rendered, - "Review src/lib.rs in test. Also see docs/readme.md. Use {literal}." - ); - } - - #[test] - fn render_instruction_template_leaves_unknown_placeholders() { - let row = json!({ - "path": "src/lib.rs", - }); - let rendered = render_instruction_template("Check {path} then {missing}", &row); - assert_eq!(rendered, "Check src/lib.rs then {missing}"); - } - - #[test] - fn ensure_unique_headers_rejects_duplicates() { - let headers = vec!["path".to_string(), "path".to_string()]; - let Err(err) = ensure_unique_headers(headers.as_slice()) else { - panic!("expected duplicate header error"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel("csv header path is duplicated".to_string()) - ); - } -} +#[path = "agent_jobs_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/agent_jobs_tests.rs b/codex-rs/core/src/tools/handlers/agent_jobs_tests.rs new file mode 100644 index 0000000000..a2dbe6a480 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/agent_jobs_tests.rs @@ -0,0 +1,62 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn parse_csv_supports_quotes_and_commas() { + let input = "id,name\n1,\"alpha, beta\"\n2,gamma\n"; + let (headers, rows) = parse_csv(input).expect("csv parse"); + assert_eq!(headers, vec!["id".to_string(), "name".to_string()]); + assert_eq!( + rows, + vec![ + vec!["1".to_string(), "alpha, beta".to_string()], + vec!["2".to_string(), "gamma".to_string()] + ] + ); +} + +#[test] +fn csv_escape_quotes_when_needed() { + assert_eq!(csv_escape("simple"), "simple"); + assert_eq!(csv_escape("a,b"), "\"a,b\""); + assert_eq!(csv_escape("a\"b"), "\"a\"\"b\""); +} + +#[test] +fn render_instruction_template_expands_placeholders_and_escapes_braces() { + let row = json!({ + "path": "src/lib.rs", + "area": "test", + "file path": "docs/readme.md", + }); + let rendered = render_instruction_template( + "Review {path} in {area}. Also see {file path}. Use {{literal}}.", + &row, + ); + assert_eq!( + rendered, + "Review src/lib.rs in test. Also see docs/readme.md. Use {literal}." + ); +} + +#[test] +fn render_instruction_template_leaves_unknown_placeholders() { + let row = json!({ + "path": "src/lib.rs", + }); + let rendered = render_instruction_template("Check {path} then {missing}", &row); + assert_eq!(rendered, "Check src/lib.rs then {missing}"); +} + +#[test] +fn ensure_unique_headers_rejects_duplicates() { + let headers = vec!["path".to_string(), "path".to_string()]; + let Err(err) = ensure_unique_headers(headers.as_slice()) else { + panic!("expected duplicate header error"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("csv header path is duplicated".to_string()) + ); +} diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index b98a721e36..12fb904f4d 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -11,6 +11,8 @@ use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; +use crate::sandboxing::effective_file_system_sandbox_policy; +use crate::sandboxing::merge_permission_profiles; use crate::tools::context::FunctionToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; @@ -89,6 +91,38 @@ fn write_permissions_for_paths(file_paths: &[AbsolutePathBuf]) -> Option ( + Vec, + crate::tools::handlers::EffectiveAdditionalPermissions, + codex_protocol::permissions::FileSystemSandboxPolicy, +) { + let file_paths = file_paths_for_action(action); + let granted_permissions = merge_permission_profiles( + session.granted_session_permissions().await.as_ref(), + session.granted_turn_permissions().await.as_ref(), + ); + let effective_additional_permissions = apply_granted_turn_permissions( + session, + crate::sandboxing::SandboxPermissions::UseDefault, + write_permissions_for_paths(&file_paths), + ) + .await; + let file_system_sandbox_policy = effective_file_system_sandbox_policy( + &turn.file_system_sandbox_policy, + granted_permissions.as_ref(), + ); + + ( + file_paths, + effective_additional_permissions, + file_system_sandbox_policy, + ) +} + #[async_trait] impl ToolHandler for ApplyPatchHandler { type Output = FunctionToolOutput; @@ -138,20 +172,17 @@ impl ToolHandler for ApplyPatchHandler { let command = vec!["apply_patch".to_string(), patch_input.clone()]; match codex_apply_patch::maybe_parse_apply_patch_verified(&command, &cwd) { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { - match apply_patch::apply_patch(turn.as_ref(), changes).await { + let (file_paths, effective_additional_permissions, file_system_sandbox_policy) = + effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await; + match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) + .await + { InternalApplyPatchInvocation::Output(item) => { let content = item?; Ok(FunctionToolOutput::from_text(content, Some(true))) } InternalApplyPatchInvocation::DelegateToExec(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); - let file_paths = file_paths_for_action(&apply.action); - let effective_additional_permissions = apply_granted_turn_permissions( - session.as_ref(), - crate::sandboxing::SandboxPermissions::UseDefault, - write_permissions_for_paths(&file_paths), - ) - .await; let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved); let event_ctx = ToolEventCtx::new( @@ -247,20 +278,17 @@ pub(crate) async fn intercept_apply_patch( turn.as_ref(), ) .await; - match apply_patch::apply_patch(turn.as_ref(), changes).await { + let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) = + effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await; + match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) + .await + { InternalApplyPatchInvocation::Output(item) => { let content = item?; Ok(Some(FunctionToolOutput::from_text(content, Some(true)))) } InternalApplyPatchInvocation::DelegateToExec(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); - let approval_keys = file_paths_for_action(&apply.action); - let effective_additional_permissions = apply_granted_turn_permissions( - session.as_ref(), - crate::sandboxing::SandboxPermissions::UseDefault, - write_permissions_for_paths(&approval_keys), - ) - .await; let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved); let event_ctx = ToolEventCtx::new( session.as_ref(), @@ -422,6 +450,7 @@ It is important to remember: "# .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["input".to_string()]), @@ -432,33 +461,5 @@ It is important to remember: } #[cfg(test)] -mod tests { - use super::*; - use codex_apply_patch::MaybeApplyPatchVerified; - use pretty_assertions::assert_eq; - use tempfile::TempDir; - - #[test] - fn approval_keys_include_move_destination() { - let tmp = TempDir::new().expect("tmp"); - let cwd = tmp.path(); - std::fs::create_dir_all(cwd.join("old")).expect("create old dir"); - std::fs::create_dir_all(cwd.join("renamed/dir")).expect("create dest dir"); - std::fs::write(cwd.join("old/name.txt"), "old content\n").expect("write old file"); - let patch = r#"*** Begin Patch -*** Update File: old/name.txt -*** Move to: renamed/dir/name.txt -@@ --old content -+new content -*** End Patch"#; - let argv = vec!["apply_patch".to_string(), patch.to_string()]; - let action = match codex_apply_patch::maybe_parse_apply_patch_verified(&argv, cwd) { - MaybeApplyPatchVerified::Body(action) => action, - other => panic!("expected patch body, got: {other:?}"), - }; - - let keys = file_paths_for_action(&action); - assert_eq!(keys.len(), 2); - } -} +#[path = "apply_patch_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs new file mode 100644 index 0000000000..7f8e8df8b5 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -0,0 +1,28 @@ +use super::*; +use codex_apply_patch::MaybeApplyPatchVerified; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[test] +fn approval_keys_include_move_destination() { + let tmp = TempDir::new().expect("tmp"); + let cwd = tmp.path(); + std::fs::create_dir_all(cwd.join("old")).expect("create old dir"); + std::fs::create_dir_all(cwd.join("renamed/dir")).expect("create dest dir"); + std::fs::write(cwd.join("old/name.txt"), "old content\n").expect("write old file"); + let patch = r#"*** Begin Patch +*** Update File: old/name.txt +*** Move to: renamed/dir/name.txt +@@ +-old content ++new content +*** End Patch"#; + let argv = vec!["apply_patch".to_string(), patch.to_string()]; + let action = match codex_apply_patch::maybe_parse_apply_patch_verified(&argv, cwd) { + MaybeApplyPatchVerified::Body(action) => action, + other => panic!("expected patch body, got: {other:?}"), + }; + + let keys = file_paths_for_action(&action); + assert_eq!(keys.len(), 2); +} diff --git a/codex-rs/core/src/tools/handlers/artifacts.rs b/codex-rs/core/src/tools/handlers/artifacts.rs index df239495ee..bbcbcd3c80 100644 --- a/codex-rs/core/src/tools/handlers/artifacts.rs +++ b/codex-rs/core/src/tools/handlers/artifacts.rs @@ -293,130 +293,5 @@ fn error_output(error: &ArtifactsError) -> ArtifactCommandOutput { } #[cfg(test)] -mod tests { - use super::*; - use codex_artifacts::RuntimeEntrypoints; - use codex_artifacts::RuntimePathEntry; - use tempfile::TempDir; - - #[test] - fn parse_freeform_args_without_pragma() { - let args = parse_freeform_args("console.log('ok');").expect("parse args"); - assert_eq!(args.source, "console.log('ok');"); - assert_eq!(args.timeout_ms, None); - } - - #[test] - fn parse_freeform_args_with_pragma() { - let args = parse_freeform_args("// codex-artifacts: timeout_ms=45000\nconsole.log('ok');") - .expect("parse args"); - assert_eq!(args.source, "console.log('ok');"); - assert_eq!(args.timeout_ms, Some(45_000)); - } - - #[test] - fn parse_freeform_args_with_artifact_tool_pragma() { - let args = - parse_freeform_args("// codex-artifact-tool: timeout_ms=45000\nconsole.log('ok');") - .expect("parse args"); - assert_eq!(args.source, "console.log('ok');"); - assert_eq!(args.timeout_ms, Some(45_000)); - } - - #[test] - fn parse_freeform_args_rejects_json_wrapped_code() { - let err = - parse_freeform_args("{\"code\":\"console.log('ok')\"}").expect_err("expected error"); - assert!( - err.to_string() - .contains("artifacts is a freeform tool and expects raw JavaScript source") - ); - } - - #[test] - fn default_runtime_manager_uses_openai_codex_release_base() { - let codex_home = TempDir::new().expect("create temp codex home"); - let manager = default_runtime_manager(codex_home.path().to_path_buf()); - - assert_eq!( - manager.config().release().base_url().as_str(), - "https://github.com/openai/codex/releases/download/" - ); - assert_eq!( - manager.config().release().runtime_version(), - PINNED_ARTIFACT_RUNTIME_VERSION - ); - } - - #[test] - fn load_cached_runtime_reads_pinned_cache_path() { - let codex_home = TempDir::new().expect("create temp codex home"); - let platform = - codex_artifacts::ArtifactRuntimePlatform::detect_current().expect("detect platform"); - let install_dir = codex_home - .path() - .join("packages") - .join("artifacts") - .join(PINNED_ARTIFACT_RUNTIME_VERSION) - .join(platform.as_str()); - std::fs::create_dir_all(&install_dir).expect("create install dir"); - std::fs::write( - install_dir.join("manifest.json"), - serde_json::json!({ - "schema_version": 1, - "runtime_version": PINNED_ARTIFACT_RUNTIME_VERSION, - "node": { "relative_path": "node/bin/node" }, - "entrypoints": { - "build_js": { "relative_path": "artifact-tool/dist/artifact_tool.mjs" }, - "render_cli": { "relative_path": "granola-render/dist/render_cli.mjs" } - } - }) - .to_string(), - ) - .expect("write manifest"); - std::fs::create_dir_all(install_dir.join("artifact-tool/dist")) - .expect("create build entrypoint dir"); - std::fs::create_dir_all(install_dir.join("granola-render/dist")) - .expect("create render entrypoint dir"); - std::fs::write( - install_dir.join("artifact-tool/dist/artifact_tool.mjs"), - "export const ok = true;\n", - ) - .expect("write build entrypoint"); - std::fs::write( - install_dir.join("granola-render/dist/render_cli.mjs"), - "export const ok = true;\n", - ) - .expect("write render entrypoint"); - - let runtime = codex_artifacts::load_cached_runtime( - &codex_home - .path() - .join(codex_artifacts::DEFAULT_CACHE_ROOT_RELATIVE), - PINNED_ARTIFACT_RUNTIME_VERSION, - ) - .expect("resolve runtime"); - assert_eq!(runtime.runtime_version(), PINNED_ARTIFACT_RUNTIME_VERSION); - assert_eq!( - runtime.manifest().entrypoints, - RuntimeEntrypoints { - build_js: RuntimePathEntry { - relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(), - }, - render_cli: RuntimePathEntry { - relative_path: "granola-render/dist/render_cli.mjs".to_string(), - }, - } - ); - } - - #[test] - fn format_artifact_output_includes_success_message_when_silent() { - let formatted = format_artifact_output(&ArtifactCommandOutput { - exit_code: Some(0), - stdout: String::new(), - stderr: String::new(), - }); - assert!(formatted.contains("artifact JS completed successfully.")); - } -} +#[path = "artifacts_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/artifacts_tests.rs b/codex-rs/core/src/tools/handlers/artifacts_tests.rs new file mode 100644 index 0000000000..f28636acc6 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/artifacts_tests.rs @@ -0,0 +1,123 @@ +use super::*; +use codex_artifacts::RuntimeEntrypoints; +use codex_artifacts::RuntimePathEntry; +use tempfile::TempDir; + +#[test] +fn parse_freeform_args_without_pragma() { + let args = parse_freeform_args("console.log('ok');").expect("parse args"); + assert_eq!(args.source, "console.log('ok');"); + assert_eq!(args.timeout_ms, None); +} + +#[test] +fn parse_freeform_args_with_pragma() { + let args = parse_freeform_args("// codex-artifacts: timeout_ms=45000\nconsole.log('ok');") + .expect("parse args"); + assert_eq!(args.source, "console.log('ok');"); + assert_eq!(args.timeout_ms, Some(45_000)); +} + +#[test] +fn parse_freeform_args_with_artifact_tool_pragma() { + let args = parse_freeform_args("// codex-artifact-tool: timeout_ms=45000\nconsole.log('ok');") + .expect("parse args"); + assert_eq!(args.source, "console.log('ok');"); + assert_eq!(args.timeout_ms, Some(45_000)); +} + +#[test] +fn parse_freeform_args_rejects_json_wrapped_code() { + let err = parse_freeform_args("{\"code\":\"console.log('ok')\"}").expect_err("expected error"); + assert!( + err.to_string() + .contains("artifacts is a freeform tool and expects raw JavaScript source") + ); +} + +#[test] +fn default_runtime_manager_uses_openai_codex_release_base() { + let codex_home = TempDir::new().expect("create temp codex home"); + let manager = default_runtime_manager(codex_home.path().to_path_buf()); + + assert_eq!( + manager.config().release().base_url().as_str(), + "https://github.com/openai/codex/releases/download/" + ); + assert_eq!( + manager.config().release().runtime_version(), + PINNED_ARTIFACT_RUNTIME_VERSION + ); +} + +#[test] +fn load_cached_runtime_reads_pinned_cache_path() { + let codex_home = TempDir::new().expect("create temp codex home"); + let platform = + codex_artifacts::ArtifactRuntimePlatform::detect_current().expect("detect platform"); + let install_dir = codex_home + .path() + .join("packages") + .join("artifacts") + .join(PINNED_ARTIFACT_RUNTIME_VERSION) + .join(platform.as_str()); + std::fs::create_dir_all(&install_dir).expect("create install dir"); + std::fs::write( + install_dir.join("manifest.json"), + serde_json::json!({ + "schema_version": 1, + "runtime_version": PINNED_ARTIFACT_RUNTIME_VERSION, + "node": { "relative_path": "node/bin/node" }, + "entrypoints": { + "build_js": { "relative_path": "artifact-tool/dist/artifact_tool.mjs" }, + "render_cli": { "relative_path": "granola-render/dist/render_cli.mjs" } + } + }) + .to_string(), + ) + .expect("write manifest"); + std::fs::create_dir_all(install_dir.join("artifact-tool/dist")) + .expect("create build entrypoint dir"); + std::fs::create_dir_all(install_dir.join("granola-render/dist")) + .expect("create render entrypoint dir"); + std::fs::write( + install_dir.join("artifact-tool/dist/artifact_tool.mjs"), + "export const ok = true;\n", + ) + .expect("write build entrypoint"); + std::fs::write( + install_dir.join("granola-render/dist/render_cli.mjs"), + "export const ok = true;\n", + ) + .expect("write render entrypoint"); + + let runtime = codex_artifacts::load_cached_runtime( + &codex_home + .path() + .join(codex_artifacts::DEFAULT_CACHE_ROOT_RELATIVE), + PINNED_ARTIFACT_RUNTIME_VERSION, + ) + .expect("resolve runtime"); + assert_eq!(runtime.runtime_version(), PINNED_ARTIFACT_RUNTIME_VERSION); + assert_eq!( + runtime.manifest().entrypoints, + RuntimeEntrypoints { + build_js: RuntimePathEntry { + relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(), + }, + render_cli: RuntimePathEntry { + relative_path: "granola-render/dist/render_cli.mjs".to_string(), + }, + } + ); +} + +#[test] +fn format_artifact_output_includes_success_message_when_silent() { + let formatted = format_artifact_output(&ArtifactCommandOutput { + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + }); + assert!(formatted.contains("artifact JS completed successfully.")); +} diff --git a/codex-rs/core/src/tools/handlers/code_mode.rs b/codex-rs/core/src/tools/handlers/code_mode.rs deleted file mode 100644 index 4763a69b46..0000000000 --- a/codex-rs/core/src/tools/handlers/code_mode.rs +++ /dev/null @@ -1,53 +0,0 @@ -use async_trait::async_trait; - -use crate::features::Feature; -use crate::function_tool::FunctionCallError; -use crate::tools::code_mode; -use crate::tools::code_mode::PUBLIC_TOOL_NAME; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct CodeModeHandler; - -#[async_trait] -impl ToolHandler for CodeModeHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Custom { .. }) - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - payload, - .. - } = invocation; - - if !session.features().enabled(Feature::CodeMode) { - return Err(FunctionCallError::RespondToModel(format!( - "{PUBLIC_TOOL_NAME} is disabled by feature flag" - ))); - } - - let code = match payload { - ToolPayload::Custom { input } => input, - _ => { - return Err(FunctionCallError::RespondToModel(format!( - "{PUBLIC_TOOL_NAME} expects raw JavaScript source text" - ))); - } - }; - - code_mode::execute(session, turn, tracker, code).await - } -} diff --git a/codex-rs/core/src/tools/handlers/grep_files.rs b/codex-rs/core/src/tools/handlers/grep_files.rs index 071ecec70c..fdb0fce7be 100644 --- a/codex-rs/core/src/tools/handlers/grep_files.rs +++ b/codex-rs/core/src/tools/handlers/grep_files.rs @@ -172,100 +172,5 @@ fn parse_results(stdout: &[u8], limit: usize) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use std::process::Command as StdCommand; - use tempfile::tempdir; - - #[test] - fn parses_basic_results() { - let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n"; - let parsed = parse_results(stdout, 10); - assert_eq!( - parsed, - vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()] - ); - } - - #[test] - fn parse_truncates_after_limit() { - let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n/tmp/file_c.rs\n"; - let parsed = parse_results(stdout, 2); - assert_eq!( - parsed, - vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()] - ); - } - - #[tokio::test] - async fn run_search_returns_results() -> anyhow::Result<()> { - if !rg_available() { - return Ok(()); - } - let temp = tempdir().expect("create temp dir"); - let dir = temp.path(); - std::fs::write(dir.join("match_one.txt"), "alpha beta gamma").unwrap(); - std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap(); - std::fs::write(dir.join("other.txt"), "omega").unwrap(); - - let results = run_rg_search("alpha", None, dir, 10, dir).await?; - assert_eq!(results.len(), 2); - assert!(results.iter().any(|path| path.ends_with("match_one.txt"))); - assert!(results.iter().any(|path| path.ends_with("match_two.txt"))); - Ok(()) - } - - #[tokio::test] - async fn run_search_with_glob_filter() -> anyhow::Result<()> { - if !rg_available() { - return Ok(()); - } - let temp = tempdir().expect("create temp dir"); - let dir = temp.path(); - std::fs::write(dir.join("match_one.rs"), "alpha beta gamma").unwrap(); - std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap(); - - let results = run_rg_search("alpha", Some("*.rs"), dir, 10, dir).await?; - assert_eq!(results.len(), 1); - assert!(results.iter().all(|path| path.ends_with("match_one.rs"))); - Ok(()) - } - - #[tokio::test] - async fn run_search_respects_limit() -> anyhow::Result<()> { - if !rg_available() { - return Ok(()); - } - let temp = tempdir().expect("create temp dir"); - let dir = temp.path(); - std::fs::write(dir.join("one.txt"), "alpha one").unwrap(); - std::fs::write(dir.join("two.txt"), "alpha two").unwrap(); - std::fs::write(dir.join("three.txt"), "alpha three").unwrap(); - - let results = run_rg_search("alpha", None, dir, 2, dir).await?; - assert_eq!(results.len(), 2); - Ok(()) - } - - #[tokio::test] - async fn run_search_handles_no_matches() -> anyhow::Result<()> { - if !rg_available() { - return Ok(()); - } - let temp = tempdir().expect("create temp dir"); - let dir = temp.path(); - std::fs::write(dir.join("one.txt"), "omega").unwrap(); - - let results = run_rg_search("alpha", None, dir, 5, dir).await?; - assert!(results.is_empty()); - Ok(()) - } - - fn rg_available() -> bool { - StdCommand::new("rg") - .arg("--version") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) - } -} +#[path = "grep_files_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/grep_files_tests.rs b/codex-rs/core/src/tools/handlers/grep_files_tests.rs new file mode 100644 index 0000000000..0cc247c6f1 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/grep_files_tests.rs @@ -0,0 +1,95 @@ +use super::*; +use std::process::Command as StdCommand; +use tempfile::tempdir; + +#[test] +fn parses_basic_results() { + let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n"; + let parsed = parse_results(stdout, 10); + assert_eq!( + parsed, + vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()] + ); +} + +#[test] +fn parse_truncates_after_limit() { + let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n/tmp/file_c.rs\n"; + let parsed = parse_results(stdout, 2); + assert_eq!( + parsed, + vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()] + ); +} + +#[tokio::test] +async fn run_search_returns_results() -> anyhow::Result<()> { + if !rg_available() { + return Ok(()); + } + let temp = tempdir().expect("create temp dir"); + let dir = temp.path(); + std::fs::write(dir.join("match_one.txt"), "alpha beta gamma").unwrap(); + std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap(); + std::fs::write(dir.join("other.txt"), "omega").unwrap(); + + let results = run_rg_search("alpha", None, dir, 10, dir).await?; + assert_eq!(results.len(), 2); + assert!(results.iter().any(|path| path.ends_with("match_one.txt"))); + assert!(results.iter().any(|path| path.ends_with("match_two.txt"))); + Ok(()) +} + +#[tokio::test] +async fn run_search_with_glob_filter() -> anyhow::Result<()> { + if !rg_available() { + return Ok(()); + } + let temp = tempdir().expect("create temp dir"); + let dir = temp.path(); + std::fs::write(dir.join("match_one.rs"), "alpha beta gamma").unwrap(); + std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap(); + + let results = run_rg_search("alpha", Some("*.rs"), dir, 10, dir).await?; + assert_eq!(results.len(), 1); + assert!(results.iter().all(|path| path.ends_with("match_one.rs"))); + Ok(()) +} + +#[tokio::test] +async fn run_search_respects_limit() -> anyhow::Result<()> { + if !rg_available() { + return Ok(()); + } + let temp = tempdir().expect("create temp dir"); + let dir = temp.path(); + std::fs::write(dir.join("one.txt"), "alpha one").unwrap(); + std::fs::write(dir.join("two.txt"), "alpha two").unwrap(); + std::fs::write(dir.join("three.txt"), "alpha three").unwrap(); + + let results = run_rg_search("alpha", None, dir, 2, dir).await?; + assert_eq!(results.len(), 2); + Ok(()) +} + +#[tokio::test] +async fn run_search_handles_no_matches() -> anyhow::Result<()> { + if !rg_available() { + return Ok(()); + } + let temp = tempdir().expect("create temp dir"); + let dir = temp.path(); + std::fs::write(dir.join("one.txt"), "omega").unwrap(); + + let results = run_rg_search("alpha", None, dir, 5, dir).await?; + assert!(results.is_empty()); + Ok(()) +} + +fn rg_available() -> bool { + StdCommand::new("rg") + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} diff --git a/codex-rs/core/src/tools/handlers/js_repl.rs b/codex-rs/core/src/tools/handlers/js_repl.rs index d0404a9e23..bfb531f923 100644 --- a/codex-rs/core/src/tools/handlers/js_repl.rs +++ b/codex-rs/core/src/tools/handlers/js_repl.rs @@ -292,95 +292,5 @@ fn reject_json_or_quoted_source(code: &str) -> Result<(), FunctionCallError> { } #[cfg(test)] -mod tests { - use std::time::Duration; - - use super::parse_freeform_args; - use crate::codex::make_session_and_context_with_rx; - use crate::protocol::EventMsg; - use crate::protocol::ExecCommandSource; - use pretty_assertions::assert_eq; - - #[test] - fn parse_freeform_args_without_pragma() { - let args = parse_freeform_args("console.log('ok');").expect("parse args"); - assert_eq!(args.code, "console.log('ok');"); - assert_eq!(args.timeout_ms, None); - } - - #[test] - fn parse_freeform_args_with_pragma() { - let input = "// codex-js-repl: timeout_ms=15000\nconsole.log('ok');"; - let args = parse_freeform_args(input).expect("parse args"); - assert_eq!(args.code, "console.log('ok');"); - assert_eq!(args.timeout_ms, Some(15_000)); - } - - #[test] - fn parse_freeform_args_rejects_unknown_key() { - let err = parse_freeform_args("// codex-js-repl: nope=1\nconsole.log('ok');") - .expect_err("expected error"); - assert_eq!( - err.to_string(), - "js_repl pragma only supports timeout_ms; got `nope`" - ); - } - - #[test] - fn parse_freeform_args_rejects_reset_key() { - let err = parse_freeform_args("// codex-js-repl: reset=true\nconsole.log('ok');") - .expect_err("expected error"); - assert_eq!( - err.to_string(), - "js_repl pragma only supports timeout_ms; got `reset`" - ); - } - - #[test] - fn parse_freeform_args_rejects_json_wrapped_code() { - let err = parse_freeform_args(r#"{"code":"await doThing()"}"#).expect_err("expected error"); - assert_eq!( - err.to_string(), - "js_repl is a freeform tool and expects raw JavaScript source. Resend plain JS only (optional first line `// codex-js-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences." - ); - } - - #[tokio::test] - async fn emit_js_repl_exec_end_sends_event() { - let (session, turn, rx) = make_session_and_context_with_rx().await; - super::emit_js_repl_exec_end( - session.as_ref(), - turn.as_ref(), - "call-1", - "hello", - None, - Duration::from_millis(12), - ) - .await; - - let event = tokio::time::timeout(Duration::from_secs(5), async { - loop { - let event = rx.recv().await.expect("event"); - if let EventMsg::ExecCommandEnd(end) = event.msg { - break end; - } - } - }) - .await - .expect("timed out waiting for exec end"); - - assert_eq!(event.call_id, "call-1"); - assert_eq!(event.turn_id, turn.sub_id); - assert_eq!(event.command, vec!["js_repl".to_string()]); - assert_eq!(event.cwd, turn.cwd); - assert_eq!(event.source, ExecCommandSource::Agent); - assert_eq!(event.interaction_input, None); - assert_eq!(event.stdout, "hello"); - assert_eq!(event.stderr, ""); - assert!(event.aggregated_output.contains("hello")); - assert_eq!(event.exit_code, 0); - assert_eq!(event.duration, Duration::from_millis(12)); - assert!(event.formatted_output.contains("hello")); - assert!(!event.parsed_cmd.is_empty()); - } -} +#[path = "js_repl_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/js_repl_tests.rs b/codex-rs/core/src/tools/handlers/js_repl_tests.rs new file mode 100644 index 0000000000..14dc222ffe --- /dev/null +++ b/codex-rs/core/src/tools/handlers/js_repl_tests.rs @@ -0,0 +1,90 @@ +use std::time::Duration; + +use super::parse_freeform_args; +use crate::codex::make_session_and_context_with_rx; +use crate::protocol::EventMsg; +use crate::protocol::ExecCommandSource; +use pretty_assertions::assert_eq; + +#[test] +fn parse_freeform_args_without_pragma() { + let args = parse_freeform_args("console.log('ok');").expect("parse args"); + assert_eq!(args.code, "console.log('ok');"); + assert_eq!(args.timeout_ms, None); +} + +#[test] +fn parse_freeform_args_with_pragma() { + let input = "// codex-js-repl: timeout_ms=15000\nconsole.log('ok');"; + let args = parse_freeform_args(input).expect("parse args"); + assert_eq!(args.code, "console.log('ok');"); + assert_eq!(args.timeout_ms, Some(15_000)); +} + +#[test] +fn parse_freeform_args_rejects_unknown_key() { + let err = parse_freeform_args("// codex-js-repl: nope=1\nconsole.log('ok');") + .expect_err("expected error"); + assert_eq!( + err.to_string(), + "js_repl pragma only supports timeout_ms; got `nope`" + ); +} + +#[test] +fn parse_freeform_args_rejects_reset_key() { + let err = parse_freeform_args("// codex-js-repl: reset=true\nconsole.log('ok');") + .expect_err("expected error"); + assert_eq!( + err.to_string(), + "js_repl pragma only supports timeout_ms; got `reset`" + ); +} + +#[test] +fn parse_freeform_args_rejects_json_wrapped_code() { + let err = parse_freeform_args(r#"{"code":"await doThing()"}"#).expect_err("expected error"); + assert_eq!( + err.to_string(), + "js_repl is a freeform tool and expects raw JavaScript source. Resend plain JS only (optional first line `// codex-js-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences." + ); +} + +#[tokio::test] +async fn emit_js_repl_exec_end_sends_event() { + let (session, turn, rx) = make_session_and_context_with_rx().await; + super::emit_js_repl_exec_end( + session.as_ref(), + turn.as_ref(), + "call-1", + "hello", + None, + Duration::from_millis(12), + ) + .await; + + let event = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let event = rx.recv().await.expect("event"); + if let EventMsg::ExecCommandEnd(end) = event.msg { + break end; + } + } + }) + .await + .expect("timed out waiting for exec end"); + + assert_eq!(event.call_id, "call-1"); + assert_eq!(event.turn_id, turn.sub_id); + assert_eq!(event.command, vec!["js_repl".to_string()]); + assert_eq!(event.cwd, turn.cwd); + assert_eq!(event.source, ExecCommandSource::Agent); + assert_eq!(event.interaction_input, None); + assert_eq!(event.stdout, "hello"); + assert_eq!(event.stderr, ""); + assert!(event.aggregated_output.contains("hello")); + assert_eq!(event.exit_code, 0); + assert_eq!(event.duration, Duration::from_millis(12)); + assert!(event.formatted_output.contains("hello")); + assert!(!event.parsed_cmd.is_empty()); +} diff --git a/codex-rs/core/src/tools/handlers/list_dir.rs b/codex-rs/core/src/tools/handlers/list_dir.rs index fb65b32823..fd461e82e5 100644 --- a/codex-rs/core/src/tools/handlers/list_dir.rs +++ b/codex-rs/core/src/tools/handlers/list_dir.rs @@ -267,246 +267,5 @@ impl From<&FileType> for DirEntryKind { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::tempdir; - - #[tokio::test] - async fn lists_directory_entries() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - - let sub_dir = dir_path.join("nested"); - tokio::fs::create_dir(&sub_dir) - .await - .expect("create sub dir"); - - let deeper_dir = sub_dir.join("deeper"); - tokio::fs::create_dir(&deeper_dir) - .await - .expect("create deeper dir"); - - tokio::fs::write(dir_path.join("entry.txt"), b"content") - .await - .expect("write file"); - tokio::fs::write(sub_dir.join("child.txt"), b"child") - .await - .expect("write child"); - tokio::fs::write(deeper_dir.join("grandchild.txt"), b"grandchild") - .await - .expect("write grandchild"); - - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - let link_path = dir_path.join("link"); - symlink(dir_path.join("entry.txt"), &link_path).expect("create symlink"); - } - - let entries = list_dir_slice(dir_path, 1, 20, 3) - .await - .expect("list directory"); - - #[cfg(unix)] - let expected = vec![ - "entry.txt".to_string(), - "link@".to_string(), - "nested/".to_string(), - " child.txt".to_string(), - " deeper/".to_string(), - " grandchild.txt".to_string(), - ]; - - #[cfg(not(unix))] - let expected = vec![ - "entry.txt".to_string(), - "nested/".to_string(), - " child.txt".to_string(), - " deeper/".to_string(), - " grandchild.txt".to_string(), - ]; - - assert_eq!(entries, expected); - } - - #[tokio::test] - async fn errors_when_offset_exceeds_entries() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - tokio::fs::create_dir(dir_path.join("nested")) - .await - .expect("create sub dir"); - - let err = list_dir_slice(dir_path, 10, 1, 2) - .await - .expect_err("offset exceeds entries"); - assert_eq!( - err, - FunctionCallError::RespondToModel("offset exceeds directory entry count".to_string()) - ); - } - - #[tokio::test] - async fn respects_depth_parameter() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - let nested = dir_path.join("nested"); - let deeper = nested.join("deeper"); - tokio::fs::create_dir(&nested).await.expect("create nested"); - tokio::fs::create_dir(&deeper).await.expect("create deeper"); - tokio::fs::write(dir_path.join("root.txt"), b"root") - .await - .expect("write root"); - tokio::fs::write(nested.join("child.txt"), b"child") - .await - .expect("write nested"); - tokio::fs::write(deeper.join("grandchild.txt"), b"deep") - .await - .expect("write deeper"); - - let entries_depth_one = list_dir_slice(dir_path, 1, 10, 1) - .await - .expect("list depth 1"); - assert_eq!( - entries_depth_one, - vec!["nested/".to_string(), "root.txt".to_string(),] - ); - - let entries_depth_two = list_dir_slice(dir_path, 1, 20, 2) - .await - .expect("list depth 2"); - assert_eq!( - entries_depth_two, - vec![ - "nested/".to_string(), - " child.txt".to_string(), - " deeper/".to_string(), - "root.txt".to_string(), - ] - ); - - let entries_depth_three = list_dir_slice(dir_path, 1, 30, 3) - .await - .expect("list depth 3"); - assert_eq!( - entries_depth_three, - vec![ - "nested/".to_string(), - " child.txt".to_string(), - " deeper/".to_string(), - " grandchild.txt".to_string(), - "root.txt".to_string(), - ] - ); - } - - #[tokio::test] - async fn paginates_in_sorted_order() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - - let dir_a = dir_path.join("a"); - let dir_b = dir_path.join("b"); - tokio::fs::create_dir(&dir_a).await.expect("create a"); - tokio::fs::create_dir(&dir_b).await.expect("create b"); - - tokio::fs::write(dir_a.join("a_child.txt"), b"a") - .await - .expect("write a child"); - tokio::fs::write(dir_b.join("b_child.txt"), b"b") - .await - .expect("write b child"); - - let first_page = list_dir_slice(dir_path, 1, 2, 2) - .await - .expect("list page one"); - assert_eq!( - first_page, - vec![ - "a/".to_string(), - " a_child.txt".to_string(), - "More than 2 entries found".to_string() - ] - ); - - let second_page = list_dir_slice(dir_path, 3, 2, 2) - .await - .expect("list page two"); - assert_eq!( - second_page, - vec!["b/".to_string(), " b_child.txt".to_string()] - ); - } - - #[tokio::test] - async fn handles_large_limit_without_overflow() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - tokio::fs::write(dir_path.join("alpha.txt"), b"alpha") - .await - .expect("write alpha"); - tokio::fs::write(dir_path.join("beta.txt"), b"beta") - .await - .expect("write beta"); - tokio::fs::write(dir_path.join("gamma.txt"), b"gamma") - .await - .expect("write gamma"); - - let entries = list_dir_slice(dir_path, 2, usize::MAX, 1) - .await - .expect("list without overflow"); - assert_eq!( - entries, - vec!["beta.txt".to_string(), "gamma.txt".to_string(),] - ); - } - - #[tokio::test] - async fn indicates_truncated_results() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - - for idx in 0..40 { - let file = dir_path.join(format!("file_{idx:02}.txt")); - tokio::fs::write(file, b"content") - .await - .expect("write file"); - } - - let entries = list_dir_slice(dir_path, 1, 25, 1) - .await - .expect("list directory"); - assert_eq!(entries.len(), 26); - assert_eq!( - entries.last(), - Some(&"More than 25 entries found".to_string()) - ); - } - - #[tokio::test] - async fn truncation_respects_sorted_order() -> anyhow::Result<()> { - let temp = tempdir()?; - let dir_path = temp.path(); - let nested = dir_path.join("nested"); - let deeper = nested.join("deeper"); - tokio::fs::create_dir(&nested).await?; - tokio::fs::create_dir(&deeper).await?; - tokio::fs::write(dir_path.join("root.txt"), b"root").await?; - tokio::fs::write(nested.join("child.txt"), b"child").await?; - tokio::fs::write(deeper.join("grandchild.txt"), b"deep").await?; - - let entries_depth_three = list_dir_slice(dir_path, 1, 3, 3).await?; - assert_eq!( - entries_depth_three, - vec![ - "nested/".to_string(), - " child.txt".to_string(), - " deeper/".to_string(), - "More than 3 entries found".to_string() - ] - ); - - Ok(()) - } -} +#[path = "list_dir_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/list_dir_tests.rs b/codex-rs/core/src/tools/handlers/list_dir_tests.rs new file mode 100644 index 0000000000..8e3991a758 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/list_dir_tests.rs @@ -0,0 +1,241 @@ +use super::*; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +#[tokio::test] +async fn lists_directory_entries() { + let temp = tempdir().expect("create tempdir"); + let dir_path = temp.path(); + + let sub_dir = dir_path.join("nested"); + tokio::fs::create_dir(&sub_dir) + .await + .expect("create sub dir"); + + let deeper_dir = sub_dir.join("deeper"); + tokio::fs::create_dir(&deeper_dir) + .await + .expect("create deeper dir"); + + tokio::fs::write(dir_path.join("entry.txt"), b"content") + .await + .expect("write file"); + tokio::fs::write(sub_dir.join("child.txt"), b"child") + .await + .expect("write child"); + tokio::fs::write(deeper_dir.join("grandchild.txt"), b"grandchild") + .await + .expect("write grandchild"); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let link_path = dir_path.join("link"); + symlink(dir_path.join("entry.txt"), &link_path).expect("create symlink"); + } + + let entries = list_dir_slice(dir_path, 1, 20, 3) + .await + .expect("list directory"); + + #[cfg(unix)] + let expected = vec![ + "entry.txt".to_string(), + "link@".to_string(), + "nested/".to_string(), + " child.txt".to_string(), + " deeper/".to_string(), + " grandchild.txt".to_string(), + ]; + + #[cfg(not(unix))] + let expected = vec![ + "entry.txt".to_string(), + "nested/".to_string(), + " child.txt".to_string(), + " deeper/".to_string(), + " grandchild.txt".to_string(), + ]; + + assert_eq!(entries, expected); +} + +#[tokio::test] +async fn errors_when_offset_exceeds_entries() { + let temp = tempdir().expect("create tempdir"); + let dir_path = temp.path(); + tokio::fs::create_dir(dir_path.join("nested")) + .await + .expect("create sub dir"); + + let err = list_dir_slice(dir_path, 10, 1, 2) + .await + .expect_err("offset exceeds entries"); + assert_eq!( + err, + FunctionCallError::RespondToModel("offset exceeds directory entry count".to_string()) + ); +} + +#[tokio::test] +async fn respects_depth_parameter() { + let temp = tempdir().expect("create tempdir"); + let dir_path = temp.path(); + let nested = dir_path.join("nested"); + let deeper = nested.join("deeper"); + tokio::fs::create_dir(&nested).await.expect("create nested"); + tokio::fs::create_dir(&deeper).await.expect("create deeper"); + tokio::fs::write(dir_path.join("root.txt"), b"root") + .await + .expect("write root"); + tokio::fs::write(nested.join("child.txt"), b"child") + .await + .expect("write nested"); + tokio::fs::write(deeper.join("grandchild.txt"), b"deep") + .await + .expect("write deeper"); + + let entries_depth_one = list_dir_slice(dir_path, 1, 10, 1) + .await + .expect("list depth 1"); + assert_eq!( + entries_depth_one, + vec!["nested/".to_string(), "root.txt".to_string(),] + ); + + let entries_depth_two = list_dir_slice(dir_path, 1, 20, 2) + .await + .expect("list depth 2"); + assert_eq!( + entries_depth_two, + vec![ + "nested/".to_string(), + " child.txt".to_string(), + " deeper/".to_string(), + "root.txt".to_string(), + ] + ); + + let entries_depth_three = list_dir_slice(dir_path, 1, 30, 3) + .await + .expect("list depth 3"); + assert_eq!( + entries_depth_three, + vec![ + "nested/".to_string(), + " child.txt".to_string(), + " deeper/".to_string(), + " grandchild.txt".to_string(), + "root.txt".to_string(), + ] + ); +} + +#[tokio::test] +async fn paginates_in_sorted_order() { + let temp = tempdir().expect("create tempdir"); + let dir_path = temp.path(); + + let dir_a = dir_path.join("a"); + let dir_b = dir_path.join("b"); + tokio::fs::create_dir(&dir_a).await.expect("create a"); + tokio::fs::create_dir(&dir_b).await.expect("create b"); + + tokio::fs::write(dir_a.join("a_child.txt"), b"a") + .await + .expect("write a child"); + tokio::fs::write(dir_b.join("b_child.txt"), b"b") + .await + .expect("write b child"); + + let first_page = list_dir_slice(dir_path, 1, 2, 2) + .await + .expect("list page one"); + assert_eq!( + first_page, + vec![ + "a/".to_string(), + " a_child.txt".to_string(), + "More than 2 entries found".to_string() + ] + ); + + let second_page = list_dir_slice(dir_path, 3, 2, 2) + .await + .expect("list page two"); + assert_eq!( + second_page, + vec!["b/".to_string(), " b_child.txt".to_string()] + ); +} + +#[tokio::test] +async fn handles_large_limit_without_overflow() { + let temp = tempdir().expect("create tempdir"); + let dir_path = temp.path(); + tokio::fs::write(dir_path.join("alpha.txt"), b"alpha") + .await + .expect("write alpha"); + tokio::fs::write(dir_path.join("beta.txt"), b"beta") + .await + .expect("write beta"); + tokio::fs::write(dir_path.join("gamma.txt"), b"gamma") + .await + .expect("write gamma"); + + let entries = list_dir_slice(dir_path, 2, usize::MAX, 1) + .await + .expect("list without overflow"); + assert_eq!( + entries, + vec!["beta.txt".to_string(), "gamma.txt".to_string(),] + ); +} + +#[tokio::test] +async fn indicates_truncated_results() { + let temp = tempdir().expect("create tempdir"); + let dir_path = temp.path(); + + for idx in 0..40 { + let file = dir_path.join(format!("file_{idx:02}.txt")); + tokio::fs::write(file, b"content") + .await + .expect("write file"); + } + + let entries = list_dir_slice(dir_path, 1, 25, 1) + .await + .expect("list directory"); + assert_eq!(entries.len(), 26); + assert_eq!( + entries.last(), + Some(&"More than 25 entries found".to_string()) + ); +} + +#[tokio::test] +async fn truncation_respects_sorted_order() -> anyhow::Result<()> { + let temp = tempdir()?; + let dir_path = temp.path(); + let nested = dir_path.join("nested"); + let deeper = nested.join("deeper"); + tokio::fs::create_dir(&nested).await?; + tokio::fs::create_dir(&deeper).await?; + tokio::fs::write(dir_path.join("root.txt"), b"root").await?; + tokio::fs::write(nested.join("child.txt"), b"child").await?; + tokio::fs::write(deeper.join("grandchild.txt"), b"deep").await?; + + let entries_depth_three = list_dir_slice(dir_path, 1, 3, 3).await?; + assert_eq!( + entries_depth_three, + vec![ + "nested/".to_string(), + " child.txt".to_string(), + " deeper/".to_string(), + "More than 3 entries found".to_string() + ] + ); + + Ok(()) +} diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs index d1480063b5..02253d1080 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -663,131 +663,5 @@ where } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use rmcp::model::AnnotateAble; - use serde_json::json; - - fn resource(uri: &str, name: &str) -> Resource { - rmcp::model::RawResource { - uri: uri.to_string(), - name: name.to_string(), - title: None, - description: None, - mime_type: None, - size: None, - icons: None, - meta: None, - } - .no_annotation() - } - - fn template(uri_template: &str, name: &str) -> ResourceTemplate { - rmcp::model::RawResourceTemplate { - uri_template: uri_template.to_string(), - name: name.to_string(), - title: None, - description: None, - mime_type: None, - icons: None, - } - .no_annotation() - } - - #[test] - fn resource_with_server_serializes_server_field() { - let entry = ResourceWithServer::new("test".to_string(), resource("memo://id", "memo")); - let value = serde_json::to_value(&entry).expect("serialize resource"); - - assert_eq!(value["server"], json!("test")); - assert_eq!(value["uri"], json!("memo://id")); - assert_eq!(value["name"], json!("memo")); - } - - #[test] - fn list_resources_payload_from_single_server_copies_next_cursor() { - let result = ListResourcesResult { - meta: None, - next_cursor: Some("cursor-1".to_string()), - resources: vec![resource("memo://id", "memo")], - }; - let payload = ListResourcesPayload::from_single_server("srv".to_string(), result); - let value = serde_json::to_value(&payload).expect("serialize payload"); - - assert_eq!(value["server"], json!("srv")); - assert_eq!(value["nextCursor"], json!("cursor-1")); - let resources = value["resources"].as_array().expect("resources array"); - assert_eq!(resources.len(), 1); - assert_eq!(resources[0]["server"], json!("srv")); - } - - #[test] - fn list_resources_payload_from_all_servers_is_sorted() { - let mut map = HashMap::new(); - map.insert("beta".to_string(), vec![resource("memo://b-1", "b-1")]); - map.insert( - "alpha".to_string(), - vec![resource("memo://a-1", "a-1"), resource("memo://a-2", "a-2")], - ); - - let payload = ListResourcesPayload::from_all_servers(map); - let value = serde_json::to_value(&payload).expect("serialize payload"); - let uris: Vec = value["resources"] - .as_array() - .expect("resources array") - .iter() - .map(|entry| entry["uri"].as_str().unwrap().to_string()) - .collect(); - - assert_eq!( - uris, - vec![ - "memo://a-1".to_string(), - "memo://a-2".to_string(), - "memo://b-1".to_string() - ] - ); - } - - #[test] - fn call_tool_result_from_content_marks_success() { - let result = call_tool_result_from_content("{}", Some(true)); - assert_eq!(result.is_error, Some(false)); - assert_eq!(result.content.len(), 1); - } - - #[test] - fn parse_arguments_handles_empty_and_json() { - assert!( - parse_arguments(" \n\t").unwrap().is_none(), - "expected None for empty arguments" - ); - - assert!( - parse_arguments("null").unwrap().is_none(), - "expected None for null arguments" - ); - - let value = parse_arguments(r#"{"server":"figma"}"#) - .expect("parse json") - .expect("value present"); - assert_eq!(value["server"], json!("figma")); - } - - #[test] - fn template_with_server_serializes_server_field() { - let entry = - ResourceTemplateWithServer::new("srv".to_string(), template("memo://{id}", "memo")); - let value = serde_json::to_value(&entry).expect("serialize template"); - - assert_eq!( - value, - json!({ - "server": "srv", - "uriTemplate": "memo://{id}", - "name": "memo" - }) - ); - } -} +#[path = "mcp_resource_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/mcp_resource_tests.rs b/codex-rs/core/src/tools/handlers/mcp_resource_tests.rs new file mode 100644 index 0000000000..8a8410b0bd --- /dev/null +++ b/codex-rs/core/src/tools/handlers/mcp_resource_tests.rs @@ -0,0 +1,125 @@ +use super::*; +use pretty_assertions::assert_eq; +use rmcp::model::AnnotateAble; +use serde_json::json; + +fn resource(uri: &str, name: &str) -> Resource { + rmcp::model::RawResource { + uri: uri.to_string(), + name: name.to_string(), + title: None, + description: None, + mime_type: None, + size: None, + icons: None, + meta: None, + } + .no_annotation() +} + +fn template(uri_template: &str, name: &str) -> ResourceTemplate { + rmcp::model::RawResourceTemplate { + uri_template: uri_template.to_string(), + name: name.to_string(), + title: None, + description: None, + mime_type: None, + icons: None, + } + .no_annotation() +} + +#[test] +fn resource_with_server_serializes_server_field() { + let entry = ResourceWithServer::new("test".to_string(), resource("memo://id", "memo")); + let value = serde_json::to_value(&entry).expect("serialize resource"); + + assert_eq!(value["server"], json!("test")); + assert_eq!(value["uri"], json!("memo://id")); + assert_eq!(value["name"], json!("memo")); +} + +#[test] +fn list_resources_payload_from_single_server_copies_next_cursor() { + let result = ListResourcesResult { + meta: None, + next_cursor: Some("cursor-1".to_string()), + resources: vec![resource("memo://id", "memo")], + }; + let payload = ListResourcesPayload::from_single_server("srv".to_string(), result); + let value = serde_json::to_value(&payload).expect("serialize payload"); + + assert_eq!(value["server"], json!("srv")); + assert_eq!(value["nextCursor"], json!("cursor-1")); + let resources = value["resources"].as_array().expect("resources array"); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0]["server"], json!("srv")); +} + +#[test] +fn list_resources_payload_from_all_servers_is_sorted() { + let mut map = HashMap::new(); + map.insert("beta".to_string(), vec![resource("memo://b-1", "b-1")]); + map.insert( + "alpha".to_string(), + vec![resource("memo://a-1", "a-1"), resource("memo://a-2", "a-2")], + ); + + let payload = ListResourcesPayload::from_all_servers(map); + let value = serde_json::to_value(&payload).expect("serialize payload"); + let uris: Vec = value["resources"] + .as_array() + .expect("resources array") + .iter() + .map(|entry| entry["uri"].as_str().unwrap().to_string()) + .collect(); + + assert_eq!( + uris, + vec![ + "memo://a-1".to_string(), + "memo://a-2".to_string(), + "memo://b-1".to_string() + ] + ); +} + +#[test] +fn call_tool_result_from_content_marks_success() { + let result = call_tool_result_from_content("{}", Some(true)); + assert_eq!(result.is_error, Some(false)); + assert_eq!(result.content.len(), 1); +} + +#[test] +fn parse_arguments_handles_empty_and_json() { + assert!( + parse_arguments(" \n\t").unwrap().is_none(), + "expected None for empty arguments" + ); + + assert!( + parse_arguments("null").unwrap().is_none(), + "expected None for null arguments" + ); + + let value = parse_arguments(r#"{"server":"figma"}"#) + .expect("parse json") + .expect("value present"); + assert_eq!(value["server"], json!("figma")); +} + +#[test] +fn template_with_server_serializes_server_field() { + let entry = ResourceTemplateWithServer::new("srv".to_string(), template("memo://{id}", "memo")); + let value = serde_json::to_value(&entry).expect("serialize template"); + + assert_eq!( + value, + json!({ + "server": "srv", + "uriTemplate": "memo://{id}", + "name": "memo" + }) + ); +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 38d0f74f4c..5d8aaeba61 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,7 +1,6 @@ pub(crate) mod agent_jobs; pub mod apply_patch; mod artifacts; -mod code_mode; mod dynamic; mod grep_files; mod js_repl; @@ -13,9 +12,10 @@ mod plan; mod read_file; mod request_permissions; mod request_user_input; -mod search_tool_bm25; mod shell; mod test_sync; +mod tool_search; +mod tool_suggest; pub(crate) mod unified_exec; mod view_image; @@ -31,9 +31,10 @@ use crate::function_tool::FunctionCallError; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::merge_permission_profiles; use crate::sandboxing::normalize_additional_permissions; +pub(crate) use crate::tools::code_mode::CodeModeExecuteHandler; +pub(crate) use crate::tools::code_mode::CodeModeWaitHandler; pub use apply_patch::ApplyPatchHandler; pub use artifacts::ArtifactsHandler; -pub use code_mode::CodeModeHandler; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; @@ -50,12 +51,14 @@ pub use request_permissions::RequestPermissionsHandler; pub(crate) use request_permissions::request_permissions_tool_description; pub use request_user_input::RequestUserInputHandler; pub(crate) use request_user_input::request_user_input_tool_description; -pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT; -pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME; -pub use search_tool_bm25::SearchToolBm25Handler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; +pub(crate) use tool_search::DEFAULT_LIMIT as TOOL_SEARCH_DEFAULT_LIMIT; +pub(crate) use tool_search::TOOL_SEARCH_TOOL_NAME; +pub use tool_search::ToolSearchHandler; +pub(crate) use tool_suggest::TOOL_SUGGEST_TOOL_NAME; +pub use tool_suggest::ToolSuggestHandler; pub use unified_exec::UnifiedExecHandler; pub use view_image::ViewImageHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 26d7e681af..193a4e5e7b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -223,6 +223,8 @@ mod spawn { new_agent_nickname, new_agent_role, prompt, + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), status, } .into(), @@ -1073,1114 +1075,5 @@ fn validate_spawn_agent_reasoning_effort( } #[cfg(test)] -mod tests { - use super::*; - use crate::AuthManager; - use crate::CodexAuth; - use crate::ThreadManager; - use crate::built_in_model_providers; - use crate::codex::make_session_and_context; - use crate::config::DEFAULT_AGENT_MAX_DEPTH; - use crate::config::types::ShellEnvironmentPolicy; - use crate::function_tool::FunctionCallError; - use crate::protocol::AskForApproval; - use crate::protocol::Op; - use crate::protocol::SandboxPolicy; - use crate::protocol::SessionSource; - use crate::protocol::SubAgentSource; - use crate::tools::context::FunctionToolOutput; - use crate::turn_diff_tracker::TurnDiffTracker; - use codex_protocol::ThreadId; - use codex_protocol::models::ContentItem; - use codex_protocol::models::ResponseItem; - use codex_protocol::protocol::InitialHistory; - use codex_protocol::protocol::RolloutItem; - use pretty_assertions::assert_eq; - use serde::Deserialize; - use serde_json::json; - use std::collections::HashMap; - use std::path::PathBuf; - use std::sync::Arc; - use std::time::Duration; - use tokio::sync::Mutex; - use tokio::time::timeout; - - fn invocation( - session: Arc, - turn: Arc, - tool_name: &str, - payload: ToolPayload, - ) -> ToolInvocation { - ToolInvocation { - session, - turn, - tracker: Arc::new(Mutex::new(TurnDiffTracker::default())), - call_id: "call-1".to_string(), - tool_name: tool_name.to_string(), - payload, - } - } - - fn function_payload(args: serde_json::Value) -> ToolPayload { - ToolPayload::Function { - arguments: args.to_string(), - } - } - - fn thread_manager() -> ThreadManager { - ThreadManager::with_models_provider_for_tests( - CodexAuth::from_api_key("dummy"), - built_in_model_providers()["openai"].clone(), - ) - } - - fn expect_text_output(output: FunctionToolOutput) -> (String, Option) { - ( - codex_protocol::models::function_call_output_content_items_to_text(&output.body) - .unwrap_or_default(), - output.success, - ) - } - - #[tokio::test] - async fn handler_rejects_non_function_payloads() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "spawn_agent", - ToolPayload::Custom { - input: "hello".to_string(), - }, - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("payload should be rejected"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel( - "collab handler received unsupported payload".to_string() - ) - ); - } - - #[tokio::test] - async fn handler_rejects_unknown_tool() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "unknown_tool", - function_payload(json!({})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("tool should be rejected"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel("unsupported collab tool unknown_tool".to_string()) - ); - } - - #[tokio::test] - async fn spawn_agent_rejects_empty_message() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "spawn_agent", - function_payload(json!({"message": " "})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("empty message should be rejected"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel( - "Empty message can't be sent to an agent".to_string() - ) - ); - } - - #[tokio::test] - async fn spawn_agent_rejects_when_message_and_items_are_both_set() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "spawn_agent", - function_payload(json!({ - "message": "hello", - "items": [{"type": "mention", "name": "drive", "path": "app://drive"}] - })), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("message+items should be rejected"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel( - "Provide either message or items, but not both".to_string() - ) - ); - } - - #[tokio::test] - async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() { - #[derive(Debug, Deserialize)] - struct SpawnAgentResult { - agent_id: String, - nickname: Option, - } - - let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let mut config = (*turn.config).clone(); - let provider = built_in_model_providers()["ollama"].clone(); - config.model_provider_id = "ollama".to_string(); - config.model_provider = provider.clone(); - config - .permissions - .approval_policy - .set(AskForApproval::OnRequest) - .expect("approval policy should be set"); - turn.approval_policy - .set(AskForApproval::OnRequest) - .expect("approval policy should be set"); - turn.provider = provider; - turn.config = Arc::new(config); - - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "spawn_agent", - function_payload(json!({ - "message": "inspect this repo", - "agent_type": "explorer" - })), - ); - let output = MultiAgentHandler - .handle(invocation) - .await - .expect("spawn_agent should succeed"); - let (content, _) = expect_text_output(output); - let result: SpawnAgentResult = - serde_json::from_str(&content).expect("spawn_agent result should be json"); - let agent_id = agent_id(&result.agent_id).expect("agent_id should be valid"); - assert!( - result - .nickname - .as_deref() - .is_some_and(|nickname| !nickname.is_empty()) - ); - let snapshot = manager - .get_thread(agent_id) - .await - .expect("spawned agent thread should exist") - .config_snapshot() - .await; - assert_eq!(snapshot.approval_policy, AskForApproval::OnRequest); - assert_eq!(snapshot.model_provider_id, "ollama"); - } - - #[tokio::test] - async fn spawn_agent_errors_when_manager_dropped() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "spawn_agent", - function_payload(json!({"message": "hello"})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("spawn should fail without a manager"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel("collab manager unavailable".to_string()) - ); - } - - #[tokio::test] - async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { - fn pick_allowed_sandbox_policy( - constraint: &crate::config::Constrained, - base: SandboxPolicy, - ) -> SandboxPolicy { - let candidates = [ - SandboxPolicy::DangerFullAccess, - SandboxPolicy::new_workspace_write_policy(), - SandboxPolicy::new_read_only_policy(), - ]; - candidates - .into_iter() - .find(|candidate| *candidate != base && constraint.can_set(candidate).is_ok()) - .unwrap_or(base) - } - - #[derive(Debug, Deserialize)] - struct SpawnAgentResult { - agent_id: String, - nickname: Option, - } - - let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let expected_sandbox = pick_allowed_sandbox_policy( - &turn.config.permissions.sandbox_policy, - turn.config.permissions.sandbox_policy.get().clone(), - ); - turn.approval_policy - .set(AskForApproval::OnRequest) - .expect("approval policy should be set"); - turn.sandbox_policy - .set(expected_sandbox.clone()) - .expect("sandbox policy should be set"); - assert_ne!( - expected_sandbox, - turn.config.permissions.sandbox_policy.get().clone(), - "test requires a runtime sandbox override that differs from base config" - ); - - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "spawn_agent", - function_payload(json!({ - "message": "await this command", - "agent_type": "explorer" - })), - ); - let output = MultiAgentHandler - .handle(invocation) - .await - .expect("spawn_agent should succeed"); - let (content, _) = expect_text_output(output); - let result: SpawnAgentResult = - serde_json::from_str(&content).expect("spawn_agent result should be json"); - let agent_id = agent_id(&result.agent_id).expect("agent_id should be valid"); - assert!( - result - .nickname - .as_deref() - .is_some_and(|nickname| !nickname.is_empty()) - ); - - let snapshot = manager - .get_thread(agent_id) - .await - .expect("spawned agent thread should exist") - .config_snapshot() - .await; - assert_eq!(snapshot.sandbox_policy, expected_sandbox); - assert_eq!(snapshot.approval_policy, AskForApproval::OnRequest); - } - - #[tokio::test] - async fn spawn_agent_rejects_when_depth_limit_exceeded() { - let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - - let max_depth = turn.config.agent_max_depth; - turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: session.conversation_id, - depth: max_depth, - agent_nickname: None, - agent_role: None, - }); - - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "spawn_agent", - function_payload(json!({"message": "hello"})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("spawn should fail when depth limit exceeded"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string() - ) - ); - } - - #[tokio::test] - async fn spawn_agent_allows_depth_up_to_configured_max_depth() { - #[derive(Debug, Deserialize)] - struct SpawnAgentResult { - agent_id: String, - nickname: Option, - } - - let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - - let mut config = (*turn.config).clone(); - config.agent_max_depth = DEFAULT_AGENT_MAX_DEPTH + 1; - turn.config = Arc::new(config); - turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: session.conversation_id, - depth: DEFAULT_AGENT_MAX_DEPTH, - agent_nickname: None, - agent_role: None, - }); - - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "spawn_agent", - function_payload(json!({"message": "hello"})), - ); - let output = MultiAgentHandler - .handle(invocation) - .await - .expect("spawn should succeed within configured depth"); - let (content, success) = expect_text_output(output); - let result: SpawnAgentResult = - serde_json::from_str(&content).expect("spawn_agent result should be json"); - assert!(!result.agent_id.is_empty()); - assert!( - result - .nickname - .as_deref() - .is_some_and(|nickname| !nickname.is_empty()) - ); - assert_eq!(success, Some(true)); - } - - #[tokio::test] - async fn send_input_rejects_empty_message() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "send_input", - function_payload(json!({"id": ThreadId::new().to_string(), "message": ""})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("empty message should be rejected"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel( - "Empty message can't be sent to an agent".to_string() - ) - ); - } - - #[tokio::test] - async fn send_input_rejects_when_message_and_items_are_both_set() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "send_input", - function_payload(json!({ - "id": ThreadId::new().to_string(), - "message": "hello", - "items": [{"type": "mention", "name": "drive", "path": "app://drive"}] - })), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("message+items should be rejected"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel( - "Provide either message or items, but not both".to_string() - ) - ); - } - - #[tokio::test] - async fn send_input_rejects_invalid_id() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "send_input", - function_payload(json!({"id": "not-a-uuid", "message": "hi"})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("invalid id should be rejected"); - }; - let FunctionCallError::RespondToModel(msg) = err else { - panic!("expected respond-to-model error"); - }; - assert!(msg.starts_with("invalid agent id not-a-uuid:")); - } - - #[tokio::test] - async fn send_input_reports_missing_agent() { - let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let agent_id = ThreadId::new(); - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "send_input", - function_payload(json!({"id": agent_id.to_string(), "message": "hi"})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("missing agent should be reported"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel(format!("agent with id {agent_id} not found")) - ); - } - - #[tokio::test] - async fn send_input_interrupts_before_prompt() { - let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let config = turn.config.as_ref().clone(); - let thread = manager.start_thread(config).await.expect("start thread"); - let agent_id = thread.thread_id; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "send_input", - function_payload(json!({ - "id": agent_id.to_string(), - "message": "hi", - "interrupt": true - })), - ); - MultiAgentHandler - .handle(invocation) - .await - .expect("send_input should succeed"); - - let ops = manager.captured_ops(); - let ops_for_agent: Vec<&Op> = ops - .iter() - .filter_map(|(id, op)| (*id == agent_id).then_some(op)) - .collect(); - assert_eq!(ops_for_agent.len(), 2); - assert!(matches!(ops_for_agent[0], Op::Interrupt)); - assert!(matches!(ops_for_agent[1], Op::UserInput { .. })); - - let _ = thread - .thread - .submit(Op::Shutdown {}) - .await - .expect("shutdown should submit"); - } - - #[tokio::test] - async fn send_input_accepts_structured_items() { - let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let config = turn.config.as_ref().clone(); - let thread = manager.start_thread(config).await.expect("start thread"); - let agent_id = thread.thread_id; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "send_input", - function_payload(json!({ - "id": agent_id.to_string(), - "items": [ - {"type": "mention", "name": "drive", "path": "app://google_drive"}, - {"type": "text", "text": "read the folder"} - ] - })), - ); - MultiAgentHandler - .handle(invocation) - .await - .expect("send_input should succeed"); - - let expected = Op::UserInput { - items: vec![ - UserInput::Mention { - name: "drive".to_string(), - path: "app://google_drive".to_string(), - }, - UserInput::Text { - text: "read the folder".to_string(), - text_elements: Vec::new(), - }, - ], - final_output_json_schema: None, - }; - let captured = manager - .captured_ops() - .into_iter() - .find(|(id, op)| *id == agent_id && *op == expected); - assert_eq!(captured, Some((agent_id, expected))); - - let _ = thread - .thread - .submit(Op::Shutdown {}) - .await - .expect("shutdown should submit"); - } - - #[tokio::test] - async fn resume_agent_rejects_invalid_id() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "resume_agent", - function_payload(json!({"id": "not-a-uuid"})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("invalid id should be rejected"); - }; - let FunctionCallError::RespondToModel(msg) = err else { - panic!("expected respond-to-model error"); - }; - assert!(msg.starts_with("invalid agent id not-a-uuid:")); - } - - #[tokio::test] - async fn resume_agent_reports_missing_agent() { - let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let agent_id = ThreadId::new(); - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "resume_agent", - function_payload(json!({"id": agent_id.to_string()})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("missing agent should be reported"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel(format!("agent with id {agent_id} not found")) - ); - } - - #[tokio::test] - async fn resume_agent_noops_for_active_agent() { - let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let config = turn.config.as_ref().clone(); - let thread = manager.start_thread(config).await.expect("start thread"); - let agent_id = thread.thread_id; - let status_before = manager.agent_control().get_status(agent_id).await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "resume_agent", - function_payload(json!({"id": agent_id.to_string()})), - ); - - let output = MultiAgentHandler - .handle(invocation) - .await - .expect("resume_agent should succeed"); - let (content, success) = expect_text_output(output); - let result: resume_agent::ResumeAgentResult = - serde_json::from_str(&content).expect("resume_agent result should be json"); - assert_eq!(result.status, status_before); - assert_eq!(success, Some(true)); - - let thread_ids = manager.list_thread_ids().await; - assert_eq!(thread_ids, vec![agent_id]); - - let _ = thread - .thread - .submit(Op::Shutdown {}) - .await - .expect("shutdown should submit"); - } - - #[tokio::test] - async fn resume_agent_restores_closed_agent_and_accepts_send_input() { - let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let config = turn.config.as_ref().clone(); - let thread = manager - .resume_thread_with_history( - config, - InitialHistory::Forked(vec![RolloutItem::ResponseItem(ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "materialized".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - })]), - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), - false, - ) - .await - .expect("start thread"); - let agent_id = thread.thread_id; - let _ = manager - .agent_control() - .shutdown_agent(agent_id) - .await - .expect("shutdown agent"); - assert_eq!( - manager.agent_control().get_status(agent_id).await, - AgentStatus::NotFound - ); - let session = Arc::new(session); - let turn = Arc::new(turn); - - let resume_invocation = invocation( - session.clone(), - turn.clone(), - "resume_agent", - function_payload(json!({"id": agent_id.to_string()})), - ); - let output = MultiAgentHandler - .handle(resume_invocation) - .await - .expect("resume_agent should succeed"); - let (content, success) = expect_text_output(output); - let result: resume_agent::ResumeAgentResult = - serde_json::from_str(&content).expect("resume_agent result should be json"); - assert_ne!(result.status, AgentStatus::NotFound); - assert_eq!(success, Some(true)); - - let send_invocation = invocation( - session, - turn, - "send_input", - function_payload(json!({"id": agent_id.to_string(), "message": "hello"})), - ); - let output = MultiAgentHandler - .handle(send_invocation) - .await - .expect("send_input should succeed after resume"); - let (content, success) = expect_text_output(output); - let result: serde_json::Value = - serde_json::from_str(&content).expect("send_input result should be json"); - let submission_id = result - .get("submission_id") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - assert!(!submission_id.is_empty()); - assert_eq!(success, Some(true)); - - let _ = manager - .agent_control() - .shutdown_agent(agent_id) - .await - .expect("shutdown resumed agent"); - } - - #[tokio::test] - async fn resume_agent_rejects_when_depth_limit_exceeded() { - let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - - let max_depth = turn.config.agent_max_depth; - turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: session.conversation_id, - depth: max_depth, - agent_nickname: None, - agent_role: None, - }); - - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "resume_agent", - function_payload(json!({"id": ThreadId::new().to_string()})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("resume should fail when depth limit exceeded"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string() - ) - ); - } - - #[tokio::test] - async fn wait_rejects_non_positive_timeout() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "wait", - function_payload(json!({ - "ids": [ThreadId::new().to_string()], - "timeout_ms": 0 - })), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("non-positive timeout should be rejected"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel("timeout_ms must be greater than zero".to_string()) - ); - } - - #[tokio::test] - async fn wait_rejects_invalid_id() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "wait", - function_payload(json!({"ids": ["invalid"]})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("invalid id should be rejected"); - }; - let FunctionCallError::RespondToModel(msg) = err else { - panic!("expected respond-to-model error"); - }; - assert!(msg.starts_with("invalid agent id invalid:")); - } - - #[tokio::test] - async fn wait_rejects_empty_ids() { - let (session, turn) = make_session_and_context().await; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "wait", - function_payload(json!({"ids": []})), - ); - let Err(err) = MultiAgentHandler.handle(invocation).await else { - panic!("empty ids should be rejected"); - }; - assert_eq!( - err, - FunctionCallError::RespondToModel("ids must be non-empty".to_string()) - ); - } - - #[tokio::test] - async fn wait_returns_not_found_for_missing_agents() { - let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let id_a = ThreadId::new(); - let id_b = ThreadId::new(); - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "wait", - function_payload(json!({ - "ids": [id_a.to_string(), id_b.to_string()], - "timeout_ms": 1000 - })), - ); - let output = MultiAgentHandler - .handle(invocation) - .await - .expect("wait should succeed"); - let (content, success) = expect_text_output(output); - let result: wait::WaitResult = - serde_json::from_str(&content).expect("wait result should be json"); - assert_eq!( - result, - wait::WaitResult { - status: HashMap::from([ - (id_a, AgentStatus::NotFound), - (id_b, AgentStatus::NotFound), - ]), - timed_out: false - } - ); - assert_eq!(success, None); - } - - #[tokio::test] - async fn wait_times_out_when_status_is_not_final() { - let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let config = turn.config.as_ref().clone(); - let thread = manager.start_thread(config).await.expect("start thread"); - let agent_id = thread.thread_id; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "wait", - function_payload(json!({ - "ids": [agent_id.to_string()], - "timeout_ms": MIN_WAIT_TIMEOUT_MS - })), - ); - let output = MultiAgentHandler - .handle(invocation) - .await - .expect("wait should succeed"); - let (content, success) = expect_text_output(output); - let result: wait::WaitResult = - serde_json::from_str(&content).expect("wait result should be json"); - assert_eq!( - result, - wait::WaitResult { - status: HashMap::new(), - timed_out: true - } - ); - assert_eq!(success, None); - - let _ = thread - .thread - .submit(Op::Shutdown {}) - .await - .expect("shutdown should submit"); - } - - #[tokio::test] - async fn wait_clamps_short_timeouts_to_minimum() { - let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let config = turn.config.as_ref().clone(); - let thread = manager.start_thread(config).await.expect("start thread"); - let agent_id = thread.thread_id; - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "wait", - function_payload(json!({ - "ids": [agent_id.to_string()], - "timeout_ms": 10 - })), - ); - - let early = timeout( - Duration::from_millis(50), - MultiAgentHandler.handle(invocation), - ) - .await; - assert!( - early.is_err(), - "wait should not return before the minimum timeout clamp" - ); - - let _ = thread - .thread - .submit(Op::Shutdown {}) - .await - .expect("shutdown should submit"); - } - - #[tokio::test] - async fn wait_returns_final_status_without_timeout() { - let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let config = turn.config.as_ref().clone(); - let thread = manager.start_thread(config).await.expect("start thread"); - let agent_id = thread.thread_id; - let mut status_rx = manager - .agent_control() - .subscribe_status(agent_id) - .await - .expect("subscribe should succeed"); - - let _ = thread - .thread - .submit(Op::Shutdown {}) - .await - .expect("shutdown should submit"); - let _ = timeout(Duration::from_secs(1), status_rx.changed()) - .await - .expect("shutdown status should arrive"); - - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "wait", - function_payload(json!({ - "ids": [agent_id.to_string()], - "timeout_ms": 1000 - })), - ); - let output = MultiAgentHandler - .handle(invocation) - .await - .expect("wait should succeed"); - let (content, success) = expect_text_output(output); - let result: wait::WaitResult = - serde_json::from_str(&content).expect("wait result should be json"); - assert_eq!( - result, - wait::WaitResult { - status: HashMap::from([(agent_id, AgentStatus::Shutdown)]), - timed_out: false - } - ); - assert_eq!(success, None); - } - - #[tokio::test] - async fn close_agent_submits_shutdown_and_returns_status() { - let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); - session.services.agent_control = manager.agent_control(); - let config = turn.config.as_ref().clone(); - let thread = manager.start_thread(config).await.expect("start thread"); - let agent_id = thread.thread_id; - let status_before = manager.agent_control().get_status(agent_id).await; - - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "close_agent", - function_payload(json!({"id": agent_id.to_string()})), - ); - let output = MultiAgentHandler - .handle(invocation) - .await - .expect("close_agent should succeed"); - let (content, success) = expect_text_output(output); - let result: close_agent::CloseAgentResult = - serde_json::from_str(&content).expect("close_agent result should be json"); - assert_eq!(result.status, status_before); - assert_eq!(success, Some(true)); - - let ops = manager.captured_ops(); - let submitted_shutdown = ops - .iter() - .any(|(id, op)| *id == agent_id && matches!(op, Op::Shutdown)); - assert_eq!(submitted_shutdown, true); - - let status_after = manager.agent_control().get_status(agent_id).await; - assert_eq!(status_after, AgentStatus::NotFound); - } - - #[tokio::test] - async fn build_agent_spawn_config_uses_turn_context_values() { - fn pick_allowed_sandbox_policy( - constraint: &crate::config::Constrained, - base: SandboxPolicy, - ) -> SandboxPolicy { - let candidates = [ - SandboxPolicy::new_read_only_policy(), - SandboxPolicy::new_workspace_write_policy(), - SandboxPolicy::DangerFullAccess, - ]; - candidates - .into_iter() - .find(|candidate| *candidate != base && constraint.can_set(candidate).is_ok()) - .unwrap_or(base) - } - - let (_session, mut turn) = make_session_and_context().await; - let base_instructions = BaseInstructions { - text: "base".to_string(), - }; - turn.developer_instructions = Some("dev".to_string()); - turn.compact_prompt = Some("compact".to_string()); - turn.shell_environment_policy = ShellEnvironmentPolicy { - use_profile: true, - ..ShellEnvironmentPolicy::default() - }; - let temp_dir = tempfile::tempdir().expect("temp dir"); - turn.cwd = temp_dir.path().to_path_buf(); - turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); - let sandbox_policy = pick_allowed_sandbox_policy( - &turn.config.permissions.sandbox_policy, - turn.config.permissions.sandbox_policy.get().clone(), - ); - turn.sandbox_policy - .set(sandbox_policy) - .expect("sandbox policy set"); - turn.approval_policy - .set(AskForApproval::OnRequest) - .expect("approval policy set"); - - let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); - let mut expected = (*turn.config).clone(); - expected.base_instructions = Some(base_instructions.text); - expected.model = Some(turn.model_info.slug.clone()); - expected.model_provider = turn.provider.clone(); - expected.model_reasoning_effort = turn.reasoning_effort; - expected.model_reasoning_summary = Some(turn.reasoning_summary); - expected.developer_instructions = turn.developer_instructions.clone(); - expected.compact_prompt = turn.compact_prompt.clone(); - expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); - expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - expected.cwd = turn.cwd.clone(); - expected - .permissions - .approval_policy - .set(AskForApproval::OnRequest) - .expect("approval policy set"); - expected - .permissions - .sandbox_policy - .set(turn.sandbox_policy.get().clone()) - .expect("sandbox policy set"); - assert_eq!(config, expected); - } - - #[tokio::test] - async fn build_agent_spawn_config_preserves_base_user_instructions() { - let (_session, mut turn) = make_session_and_context().await; - let mut base_config = (*turn.config).clone(); - base_config.user_instructions = Some("base-user".to_string()); - turn.user_instructions = Some("resolved-user".to_string()); - turn.config = Arc::new(base_config.clone()); - let base_instructions = BaseInstructions { - text: "base".to_string(), - }; - - let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); - - assert_eq!(config.user_instructions, base_config.user_instructions); - } - - #[tokio::test] - async fn build_agent_resume_config_clears_base_instructions() { - let (_session, mut turn) = make_session_and_context().await; - let mut base_config = (*turn.config).clone(); - base_config.base_instructions = Some("caller-base".to_string()); - turn.config = Arc::new(base_config); - turn.approval_policy - .set(AskForApproval::OnRequest) - .expect("approval policy set"); - - let config = build_agent_resume_config(&turn, 0).expect("resume config"); - - let mut expected = (*turn.config).clone(); - expected.base_instructions = None; - expected.model = Some(turn.model_info.slug.clone()); - expected.model_provider = turn.provider.clone(); - expected.model_reasoning_effort = turn.reasoning_effort; - expected.model_reasoning_summary = Some(turn.reasoning_summary); - expected.developer_instructions = turn.developer_instructions.clone(); - expected.compact_prompt = turn.compact_prompt.clone(); - expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); - expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - expected.cwd = turn.cwd.clone(); - expected - .permissions - .approval_policy - .set(AskForApproval::OnRequest) - .expect("approval policy set"); - expected - .permissions - .sandbox_policy - .set(turn.sandbox_policy.get().clone()) - .expect("sandbox policy set"); - assert_eq!(config, expected); - } -} +#[path = "multi_agents_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs new file mode 100644 index 0000000000..844c0125d2 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -0,0 +1,1104 @@ +use super::*; +use crate::AuthManager; +use crate::CodexAuth; +use crate::ThreadManager; +use crate::built_in_model_providers; +use crate::codex::make_session_and_context; +use crate::config::DEFAULT_AGENT_MAX_DEPTH; +use crate::config::types::ShellEnvironmentPolicy; +use crate::function_tool::FunctionCallError; +use crate::protocol::AskForApproval; +use crate::protocol::Op; +use crate::protocol::SandboxPolicy; +use crate::protocol::SessionSource; +use crate::protocol::SubAgentSource; +use crate::tools::context::FunctionToolOutput; +use crate::turn_diff_tracker::TurnDiffTracker; +use codex_protocol::ThreadId; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::RolloutItem; +use pretty_assertions::assert_eq; +use serde::Deserialize; +use serde_json::json; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::time::timeout; + +fn invocation( + session: Arc, + turn: Arc, + tool_name: &str, + payload: ToolPayload, +) -> ToolInvocation { + ToolInvocation { + session, + turn, + tracker: Arc::new(Mutex::new(TurnDiffTracker::default())), + call_id: "call-1".to_string(), + tool_name: tool_name.to_string(), + tool_namespace: None, + payload, + } +} + +fn function_payload(args: serde_json::Value) -> ToolPayload { + ToolPayload::Function { + arguments: args.to_string(), + } +} + +fn thread_manager() -> ThreadManager { + ThreadManager::with_models_provider_for_tests( + CodexAuth::from_api_key("dummy"), + built_in_model_providers()["openai"].clone(), + ) +} + +fn expect_text_output(output: FunctionToolOutput) -> (String, Option) { + ( + codex_protocol::models::function_call_output_content_items_to_text(&output.body) + .unwrap_or_default(), + output.success, + ) +} + +#[tokio::test] +async fn handler_rejects_non_function_payloads() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + ToolPayload::Custom { + input: "hello".to_string(), + }, + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("payload should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel( + "collab handler received unsupported payload".to_string() + ) + ); +} + +#[tokio::test] +async fn handler_rejects_unknown_tool() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "unknown_tool", + function_payload(json!({})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("tool should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("unsupported collab tool unknown_tool".to_string()) + ); +} + +#[tokio::test] +async fn spawn_agent_rejects_empty_message() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({"message": " "})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("empty message should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("Empty message can't be sent to an agent".to_string()) + ); +} + +#[tokio::test] +async fn spawn_agent_rejects_when_message_and_items_are_both_set() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "hello", + "items": [{"type": "mention", "name": "drive", "path": "app://drive"}] + })), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("message+items should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel( + "Provide either message or items, but not both".to_string() + ) + ); +} + +#[tokio::test] +async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + nickname: Option, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let mut config = (*turn.config).clone(); + let provider = built_in_model_providers()["ollama"].clone(); + config.model_provider_id = "ollama".to_string(); + config.model_provider = provider.clone(); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("approval policy should be set"); + turn.approval_policy + .set(AskForApproval::OnRequest) + .expect("approval policy should be set"); + turn.provider = provider; + turn.config = Arc::new(config); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "agent_type": "explorer" + })), + ); + let output = MultiAgentHandler + .handle(invocation) + .await + .expect("spawn_agent should succeed"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let agent_id = agent_id(&result.agent_id).expect("agent_id should be valid"); + assert!( + result + .nickname + .as_deref() + .is_some_and(|nickname| !nickname.is_empty()) + ); + let snapshot = manager + .get_thread(agent_id) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + assert_eq!(snapshot.approval_policy, AskForApproval::OnRequest); + assert_eq!(snapshot.model_provider_id, "ollama"); +} + +#[tokio::test] +async fn spawn_agent_errors_when_manager_dropped() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({"message": "hello"})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("spawn should fail without a manager"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + ); +} + +#[tokio::test] +async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { + fn pick_allowed_sandbox_policy( + constraint: &crate::config::Constrained, + base: SandboxPolicy, + ) -> SandboxPolicy { + let candidates = [ + SandboxPolicy::DangerFullAccess, + SandboxPolicy::new_workspace_write_policy(), + SandboxPolicy::new_read_only_policy(), + ]; + candidates + .into_iter() + .find(|candidate| *candidate != base && constraint.can_set(candidate).is_ok()) + .unwrap_or(base) + } + + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + nickname: Option, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let expected_sandbox = pick_allowed_sandbox_policy( + &turn.config.permissions.sandbox_policy, + turn.config.permissions.sandbox_policy.get().clone(), + ); + turn.approval_policy + .set(AskForApproval::OnRequest) + .expect("approval policy should be set"); + turn.sandbox_policy + .set(expected_sandbox.clone()) + .expect("sandbox policy should be set"); + assert_ne!( + expected_sandbox, + turn.config.permissions.sandbox_policy.get().clone(), + "test requires a runtime sandbox override that differs from base config" + ); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "await this command", + "agent_type": "explorer" + })), + ); + let output = MultiAgentHandler + .handle(invocation) + .await + .expect("spawn_agent should succeed"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let agent_id = agent_id(&result.agent_id).expect("agent_id should be valid"); + assert!( + result + .nickname + .as_deref() + .is_some_and(|nickname| !nickname.is_empty()) + ); + + let snapshot = manager + .get_thread(agent_id) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + assert_eq!(snapshot.sandbox_policy, expected_sandbox); + assert_eq!(snapshot.approval_policy, AskForApproval::OnRequest); +} + +#[tokio::test] +async fn spawn_agent_rejects_when_depth_limit_exceeded() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + + let max_depth = turn.config.agent_max_depth; + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: session.conversation_id, + depth: max_depth, + agent_nickname: None, + agent_role: None, + }); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({"message": "hello"})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("spawn should fail when depth limit exceeded"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string() + ) + ); +} + +#[tokio::test] +async fn spawn_agent_allows_depth_up_to_configured_max_depth() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + nickname: Option, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + + let mut config = (*turn.config).clone(); + config.agent_max_depth = DEFAULT_AGENT_MAX_DEPTH + 1; + turn.config = Arc::new(config); + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: session.conversation_id, + depth: DEFAULT_AGENT_MAX_DEPTH, + agent_nickname: None, + agent_role: None, + }); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({"message": "hello"})), + ); + let output = MultiAgentHandler + .handle(invocation) + .await + .expect("spawn should succeed within configured depth"); + let (content, success) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + assert!(!result.agent_id.is_empty()); + assert!( + result + .nickname + .as_deref() + .is_some_and(|nickname| !nickname.is_empty()) + ); + assert_eq!(success, Some(true)); +} + +#[tokio::test] +async fn send_input_rejects_empty_message() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "send_input", + function_payload(json!({"id": ThreadId::new().to_string(), "message": ""})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("empty message should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("Empty message can't be sent to an agent".to_string()) + ); +} + +#[tokio::test] +async fn send_input_rejects_when_message_and_items_are_both_set() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "send_input", + function_payload(json!({ + "id": ThreadId::new().to_string(), + "message": "hello", + "items": [{"type": "mention", "name": "drive", "path": "app://drive"}] + })), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("message+items should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel( + "Provide either message or items, but not both".to_string() + ) + ); +} + +#[tokio::test] +async fn send_input_rejects_invalid_id() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "send_input", + function_payload(json!({"id": "not-a-uuid", "message": "hi"})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("invalid id should be rejected"); + }; + let FunctionCallError::RespondToModel(msg) = err else { + panic!("expected respond-to-model error"); + }; + assert!(msg.starts_with("invalid agent id not-a-uuid:")); +} + +#[tokio::test] +async fn send_input_reports_missing_agent() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let agent_id = ThreadId::new(); + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "send_input", + function_payload(json!({"id": agent_id.to_string(), "message": "hi"})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("missing agent should be reported"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel(format!("agent with id {agent_id} not found")) + ); +} + +#[tokio::test] +async fn send_input_interrupts_before_prompt() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let config = turn.config.as_ref().clone(); + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "send_input", + function_payload(json!({ + "id": agent_id.to_string(), + "message": "hi", + "interrupt": true + })), + ); + MultiAgentHandler + .handle(invocation) + .await + .expect("send_input should succeed"); + + let ops = manager.captured_ops(); + let ops_for_agent: Vec<&Op> = ops + .iter() + .filter_map(|(id, op)| (*id == agent_id).then_some(op)) + .collect(); + assert_eq!(ops_for_agent.len(), 2); + assert!(matches!(ops_for_agent[0], Op::Interrupt)); + assert!(matches!(ops_for_agent[1], Op::UserInput { .. })); + + let _ = thread + .thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); +} + +#[tokio::test] +async fn send_input_accepts_structured_items() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let config = turn.config.as_ref().clone(); + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "send_input", + function_payload(json!({ + "id": agent_id.to_string(), + "items": [ + {"type": "mention", "name": "drive", "path": "app://google_drive"}, + {"type": "text", "text": "read the folder"} + ] + })), + ); + MultiAgentHandler + .handle(invocation) + .await + .expect("send_input should succeed"); + + let expected = Op::UserInput { + items: vec![ + UserInput::Mention { + name: "drive".to_string(), + path: "app://google_drive".to_string(), + }, + UserInput::Text { + text: "read the folder".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + }; + let captured = manager + .captured_ops() + .into_iter() + .find(|(id, op)| *id == agent_id && *op == expected); + assert_eq!(captured, Some((agent_id, expected))); + + let _ = thread + .thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); +} + +#[tokio::test] +async fn resume_agent_rejects_invalid_id() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "resume_agent", + function_payload(json!({"id": "not-a-uuid"})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("invalid id should be rejected"); + }; + let FunctionCallError::RespondToModel(msg) = err else { + panic!("expected respond-to-model error"); + }; + assert!(msg.starts_with("invalid agent id not-a-uuid:")); +} + +#[tokio::test] +async fn resume_agent_reports_missing_agent() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let agent_id = ThreadId::new(); + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "resume_agent", + function_payload(json!({"id": agent_id.to_string()})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("missing agent should be reported"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel(format!("agent with id {agent_id} not found")) + ); +} + +#[tokio::test] +async fn resume_agent_noops_for_active_agent() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let config = turn.config.as_ref().clone(); + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let status_before = manager.agent_control().get_status(agent_id).await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "resume_agent", + function_payload(json!({"id": agent_id.to_string()})), + ); + + let output = MultiAgentHandler + .handle(invocation) + .await + .expect("resume_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: resume_agent::ResumeAgentResult = + serde_json::from_str(&content).expect("resume_agent result should be json"); + assert_eq!(result.status, status_before); + assert_eq!(success, Some(true)); + + let thread_ids = manager.list_thread_ids().await; + assert_eq!(thread_ids, vec![agent_id]); + + let _ = thread + .thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); +} + +#[tokio::test] +async fn resume_agent_restores_closed_agent_and_accepts_send_input() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let config = turn.config.as_ref().clone(); + let thread = manager + .resume_thread_with_history( + config, + InitialHistory::Forked(vec![RolloutItem::ResponseItem(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "materialized".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + })]), + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), + false, + None, + ) + .await + .expect("start thread"); + let agent_id = thread.thread_id; + let _ = manager + .agent_control() + .shutdown_agent(agent_id) + .await + .expect("shutdown agent"); + assert_eq!( + manager.agent_control().get_status(agent_id).await, + AgentStatus::NotFound + ); + let session = Arc::new(session); + let turn = Arc::new(turn); + + let resume_invocation = invocation( + session.clone(), + turn.clone(), + "resume_agent", + function_payload(json!({"id": agent_id.to_string()})), + ); + let output = MultiAgentHandler + .handle(resume_invocation) + .await + .expect("resume_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: resume_agent::ResumeAgentResult = + serde_json::from_str(&content).expect("resume_agent result should be json"); + assert_ne!(result.status, AgentStatus::NotFound); + assert_eq!(success, Some(true)); + + let send_invocation = invocation( + session, + turn, + "send_input", + function_payload(json!({"id": agent_id.to_string(), "message": "hello"})), + ); + let output = MultiAgentHandler + .handle(send_invocation) + .await + .expect("send_input should succeed after resume"); + let (content, success) = expect_text_output(output); + let result: serde_json::Value = + serde_json::from_str(&content).expect("send_input result should be json"); + let submission_id = result + .get("submission_id") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + assert!(!submission_id.is_empty()); + assert_eq!(success, Some(true)); + + let _ = manager + .agent_control() + .shutdown_agent(agent_id) + .await + .expect("shutdown resumed agent"); +} + +#[tokio::test] +async fn resume_agent_rejects_when_depth_limit_exceeded() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + + let max_depth = turn.config.agent_max_depth; + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: session.conversation_id, + depth: max_depth, + agent_nickname: None, + agent_role: None, + }); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "resume_agent", + function_payload(json!({"id": ThreadId::new().to_string()})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("resume should fail when depth limit exceeded"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string() + ) + ); +} + +#[tokio::test] +async fn wait_rejects_non_positive_timeout() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({ + "ids": [ThreadId::new().to_string()], + "timeout_ms": 0 + })), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("non-positive timeout should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("timeout_ms must be greater than zero".to_string()) + ); +} + +#[tokio::test] +async fn wait_rejects_invalid_id() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({"ids": ["invalid"]})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("invalid id should be rejected"); + }; + let FunctionCallError::RespondToModel(msg) = err else { + panic!("expected respond-to-model error"); + }; + assert!(msg.starts_with("invalid agent id invalid:")); +} + +#[tokio::test] +async fn wait_rejects_empty_ids() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({"ids": []})), + ); + let Err(err) = MultiAgentHandler.handle(invocation).await else { + panic!("empty ids should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("ids must be non-empty".to_string()) + ); +} + +#[tokio::test] +async fn wait_returns_not_found_for_missing_agents() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let id_a = ThreadId::new(); + let id_b = ThreadId::new(); + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({ + "ids": [id_a.to_string(), id_b.to_string()], + "timeout_ms": 1000 + })), + ); + let output = MultiAgentHandler + .handle(invocation) + .await + .expect("wait should succeed"); + let (content, success) = expect_text_output(output); + let result: wait::WaitResult = + serde_json::from_str(&content).expect("wait result should be json"); + assert_eq!( + result, + wait::WaitResult { + status: HashMap::from([(id_a, AgentStatus::NotFound), (id_b, AgentStatus::NotFound),]), + timed_out: false + } + ); + assert_eq!(success, None); +} + +#[tokio::test] +async fn wait_times_out_when_status_is_not_final() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let config = turn.config.as_ref().clone(); + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({ + "ids": [agent_id.to_string()], + "timeout_ms": MIN_WAIT_TIMEOUT_MS + })), + ); + let output = MultiAgentHandler + .handle(invocation) + .await + .expect("wait should succeed"); + let (content, success) = expect_text_output(output); + let result: wait::WaitResult = + serde_json::from_str(&content).expect("wait result should be json"); + assert_eq!( + result, + wait::WaitResult { + status: HashMap::new(), + timed_out: true + } + ); + assert_eq!(success, None); + + let _ = thread + .thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); +} + +#[tokio::test] +async fn wait_clamps_short_timeouts_to_minimum() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let config = turn.config.as_ref().clone(); + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({ + "ids": [agent_id.to_string()], + "timeout_ms": 10 + })), + ); + + let early = timeout( + Duration::from_millis(50), + MultiAgentHandler.handle(invocation), + ) + .await; + assert!( + early.is_err(), + "wait should not return before the minimum timeout clamp" + ); + + let _ = thread + .thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); +} + +#[tokio::test] +async fn wait_returns_final_status_without_timeout() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let config = turn.config.as_ref().clone(); + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let mut status_rx = manager + .agent_control() + .subscribe_status(agent_id) + .await + .expect("subscribe should succeed"); + + let _ = thread + .thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); + let _ = timeout(Duration::from_secs(1), status_rx.changed()) + .await + .expect("shutdown status should arrive"); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({ + "ids": [agent_id.to_string()], + "timeout_ms": 1000 + })), + ); + let output = MultiAgentHandler + .handle(invocation) + .await + .expect("wait should succeed"); + let (content, success) = expect_text_output(output); + let result: wait::WaitResult = + serde_json::from_str(&content).expect("wait result should be json"); + assert_eq!( + result, + wait::WaitResult { + status: HashMap::from([(agent_id, AgentStatus::Shutdown)]), + timed_out: false + } + ); + assert_eq!(success, None); +} + +#[tokio::test] +async fn close_agent_submits_shutdown_and_returns_status() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let config = turn.config.as_ref().clone(); + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let status_before = manager.agent_control().get_status(agent_id).await; + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "close_agent", + function_payload(json!({"id": agent_id.to_string()})), + ); + let output = MultiAgentHandler + .handle(invocation) + .await + .expect("close_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: close_agent::CloseAgentResult = + serde_json::from_str(&content).expect("close_agent result should be json"); + assert_eq!(result.status, status_before); + assert_eq!(success, Some(true)); + + let ops = manager.captured_ops(); + let submitted_shutdown = ops + .iter() + .any(|(id, op)| *id == agent_id && matches!(op, Op::Shutdown)); + assert_eq!(submitted_shutdown, true); + + let status_after = manager.agent_control().get_status(agent_id).await; + assert_eq!(status_after, AgentStatus::NotFound); +} + +#[tokio::test] +async fn build_agent_spawn_config_uses_turn_context_values() { + fn pick_allowed_sandbox_policy( + constraint: &crate::config::Constrained, + base: SandboxPolicy, + ) -> SandboxPolicy { + let candidates = [ + SandboxPolicy::new_read_only_policy(), + SandboxPolicy::new_workspace_write_policy(), + SandboxPolicy::DangerFullAccess, + ]; + candidates + .into_iter() + .find(|candidate| *candidate != base && constraint.can_set(candidate).is_ok()) + .unwrap_or(base) + } + + let (_session, mut turn) = make_session_and_context().await; + let base_instructions = BaseInstructions { + text: "base".to_string(), + }; + turn.developer_instructions = Some("dev".to_string()); + turn.compact_prompt = Some("compact".to_string()); + turn.shell_environment_policy = ShellEnvironmentPolicy { + use_profile: true, + ..ShellEnvironmentPolicy::default() + }; + let temp_dir = tempfile::tempdir().expect("temp dir"); + turn.cwd = temp_dir.path().to_path_buf(); + turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); + let sandbox_policy = pick_allowed_sandbox_policy( + &turn.config.permissions.sandbox_policy, + turn.config.permissions.sandbox_policy.get().clone(), + ); + turn.sandbox_policy + .set(sandbox_policy) + .expect("sandbox policy set"); + turn.approval_policy + .set(AskForApproval::OnRequest) + .expect("approval policy set"); + + let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); + let mut expected = (*turn.config).clone(); + expected.base_instructions = Some(base_instructions.text); + expected.model = Some(turn.model_info.slug.clone()); + expected.model_provider = turn.provider.clone(); + expected.model_reasoning_effort = turn.reasoning_effort; + expected.model_reasoning_summary = Some(turn.reasoning_summary); + expected.developer_instructions = turn.developer_instructions.clone(); + expected.compact_prompt = turn.compact_prompt.clone(); + expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); + expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); + expected.cwd = turn.cwd.clone(); + expected + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("approval policy set"); + expected + .permissions + .sandbox_policy + .set(turn.sandbox_policy.get().clone()) + .expect("sandbox policy set"); + assert_eq!(config, expected); +} + +#[tokio::test] +async fn build_agent_spawn_config_preserves_base_user_instructions() { + let (_session, mut turn) = make_session_and_context().await; + let mut base_config = (*turn.config).clone(); + base_config.user_instructions = Some("base-user".to_string()); + turn.user_instructions = Some("resolved-user".to_string()); + turn.config = Arc::new(base_config.clone()); + let base_instructions = BaseInstructions { + text: "base".to_string(), + }; + + let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); + + assert_eq!(config.user_instructions, base_config.user_instructions); +} + +#[tokio::test] +async fn build_agent_resume_config_clears_base_instructions() { + let (_session, mut turn) = make_session_and_context().await; + let mut base_config = (*turn.config).clone(); + base_config.base_instructions = Some("caller-base".to_string()); + turn.config = Arc::new(base_config); + turn.approval_policy + .set(AskForApproval::OnRequest) + .expect("approval policy set"); + + let config = build_agent_resume_config(&turn, 0).expect("resume config"); + + let mut expected = (*turn.config).clone(); + expected.base_instructions = None; + expected.model = Some(turn.model_info.slug.clone()); + expected.model_provider = turn.provider.clone(); + expected.model_reasoning_effort = turn.reasoning_effort; + expected.model_reasoning_summary = Some(turn.reasoning_summary); + expected.developer_instructions = turn.developer_instructions.clone(); + expected.compact_prompt = turn.compact_prompt.clone(); + expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); + expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); + expected.cwd = turn.cwd.clone(); + expected + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("approval policy set"); + expected + .permissions + .sandbox_policy + .set(turn.sandbox_policy.get().clone()) + .expect("sandbox policy set"); + assert_eq!(config, expected); +} diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index bd70418a65..9c9d3e9591 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -52,6 +52,7 @@ At most one step can be in_progress at a time. "# .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["plan".to_string()]), diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs index e88bf9baa4..b868edf5b9 100644 --- a/codex-rs/core/src/tools/handlers/read_file.rs +++ b/codex-rs/core/src/tools/handlers/read_file.rs @@ -485,508 +485,5 @@ mod defaults { } #[cfg(test)] -mod tests { - use super::indentation::read_block; - use super::slice::read; - use super::*; - use pretty_assertions::assert_eq; - use tempfile::NamedTempFile; - - #[tokio::test] - async fn reads_requested_range() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "alpha -beta -gamma -" - )?; - - let lines = read(temp.path(), 2, 2).await?; - assert_eq!(lines, vec!["L2: beta".to_string(), "L3: gamma".to_string()]); - Ok(()) - } - - #[tokio::test] - async fn errors_when_offset_exceeds_length() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - writeln!(temp, "only")?; - - let err = read(temp.path(), 3, 1) - .await - .expect_err("offset exceeds length"); - assert_eq!( - err, - FunctionCallError::RespondToModel("offset exceeds file length".to_string()) - ); - Ok(()) - } - - #[tokio::test] - async fn reads_non_utf8_lines() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - temp.as_file_mut().write_all(b"\xff\xfe\nplain\n")?; - - let lines = read(temp.path(), 1, 2).await?; - let expected_first = format!("L1: {}{}", '\u{FFFD}', '\u{FFFD}'); - assert_eq!(lines, vec![expected_first, "L2: plain".to_string()]); - Ok(()) - } - - #[tokio::test] - async fn trims_crlf_endings() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!(temp, "one\r\ntwo\r\n")?; - - let lines = read(temp.path(), 1, 2).await?; - assert_eq!(lines, vec!["L1: one".to_string(), "L2: two".to_string()]); - Ok(()) - } - - #[tokio::test] - async fn respects_limit_even_with_more_lines() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "first -second -third -" - )?; - - let lines = read(temp.path(), 1, 2).await?; - assert_eq!( - lines, - vec!["L1: first".to_string(), "L2: second".to_string()] - ); - Ok(()) - } - - #[tokio::test] - async fn truncates_lines_longer_than_max_length() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - let long_line = "x".repeat(MAX_LINE_LENGTH + 50); - writeln!(temp, "{long_line}")?; - - let lines = read(temp.path(), 1, 1).await?; - let expected = "x".repeat(MAX_LINE_LENGTH); - assert_eq!(lines, vec![format!("L1: {expected}")]); - Ok(()) - } - - #[tokio::test] - async fn indentation_mode_captures_block() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "fn outer() {{ - if cond {{ - inner(); - }} - tail(); -}} -" - )?; - - let options = IndentationArgs { - anchor_line: Some(3), - include_siblings: false, - max_levels: 1, - ..Default::default() - }; - - let lines = read_block(temp.path(), 3, 10, options).await?; - - assert_eq!( - lines, - vec![ - "L2: if cond {".to_string(), - "L3: inner();".to_string(), - "L4: }".to_string() - ] - ); - Ok(()) - } - - #[tokio::test] - async fn indentation_mode_expands_parents() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "mod root {{ - fn outer() {{ - if cond {{ - inner(); - }} - }} -}} -" - )?; - - let mut options = IndentationArgs { - anchor_line: Some(4), - max_levels: 2, - ..Default::default() - }; - - let lines = read_block(temp.path(), 4, 50, options.clone()).await?; - assert_eq!( - lines, - vec![ - "L2: fn outer() {".to_string(), - "L3: if cond {".to_string(), - "L4: inner();".to_string(), - "L5: }".to_string(), - "L6: }".to_string(), - ] - ); - - options.max_levels = 3; - let expanded = read_block(temp.path(), 4, 50, options).await?; - assert_eq!( - expanded, - vec![ - "L1: mod root {".to_string(), - "L2: fn outer() {".to_string(), - "L3: if cond {".to_string(), - "L4: inner();".to_string(), - "L5: }".to_string(), - "L6: }".to_string(), - "L7: }".to_string(), - ] - ); - Ok(()) - } - - #[tokio::test] - async fn indentation_mode_respects_sibling_flag() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "fn wrapper() {{ - if first {{ - do_first(); - }} - if second {{ - do_second(); - }} -}} -" - )?; - - let mut options = IndentationArgs { - anchor_line: Some(3), - include_siblings: false, - max_levels: 1, - ..Default::default() - }; - - let lines = read_block(temp.path(), 3, 50, options.clone()).await?; - assert_eq!( - lines, - vec![ - "L2: if first {".to_string(), - "L3: do_first();".to_string(), - "L4: }".to_string(), - ] - ); - - options.include_siblings = true; - let with_siblings = read_block(temp.path(), 3, 50, options).await?; - assert_eq!( - with_siblings, - vec![ - "L2: if first {".to_string(), - "L3: do_first();".to_string(), - "L4: }".to_string(), - "L5: if second {".to_string(), - "L6: do_second();".to_string(), - "L7: }".to_string(), - ] - ); - Ok(()) - } - - #[tokio::test] - async fn indentation_mode_handles_python_sample() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "class Foo: - def __init__(self, size): - self.size = size - def double(self, value): - if value is None: - return 0 - result = value * self.size - return result -class Bar: - def compute(self): - helper = Foo(2) - return helper.double(5) -" - )?; - - let options = IndentationArgs { - anchor_line: Some(7), - include_siblings: true, - max_levels: 1, - ..Default::default() - }; - - let lines = read_block(temp.path(), 1, 200, options).await?; - assert_eq!( - lines, - vec![ - "L2: def __init__(self, size):".to_string(), - "L3: self.size = size".to_string(), - "L4: def double(self, value):".to_string(), - "L5: if value is None:".to_string(), - "L6: return 0".to_string(), - "L7: result = value * self.size".to_string(), - "L8: return result".to_string(), - ] - ); - Ok(()) - } - - #[tokio::test] - #[ignore] - async fn indentation_mode_handles_javascript_sample() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "export function makeThing() {{ - const cache = new Map(); - function ensure(key) {{ - if (!cache.has(key)) {{ - cache.set(key, []); - }} - return cache.get(key); - }} - const handlers = {{ - init() {{ - console.log(\"init\"); - }}, - run() {{ - if (Math.random() > 0.5) {{ - return \"heads\"; - }} - return \"tails\"; - }}, - }}; - return {{ cache, handlers }}; -}} -export function other() {{ - return makeThing(); -}} -" - )?; - - let options = IndentationArgs { - anchor_line: Some(15), - max_levels: 1, - ..Default::default() - }; - - let lines = read_block(temp.path(), 15, 200, options).await?; - assert_eq!( - lines, - vec![ - "L10: init() {".to_string(), - "L11: console.log(\"init\");".to_string(), - "L12: },".to_string(), - "L13: run() {".to_string(), - "L14: if (Math.random() > 0.5) {".to_string(), - "L15: return \"heads\";".to_string(), - "L16: }".to_string(), - "L17: return \"tails\";".to_string(), - "L18: },".to_string(), - ] - ); - Ok(()) - } - - fn write_cpp_sample() -> anyhow::Result { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "#include -#include - -namespace sample {{ -class Runner {{ -public: - void setup() {{ - if (enabled_) {{ - init(); - }} - }} - - // Run the code - int run() const {{ - switch (mode_) {{ - case Mode::Fast: - return fast(); - case Mode::Slow: - return slow(); - default: - return fallback(); - }} - }} - -private: - bool enabled_ = false; - Mode mode_ = Mode::Fast; - - int fast() const {{ - return 1; - }} -}}; -}} // namespace sample -" - )?; - Ok(temp) - } - - #[tokio::test] - async fn indentation_mode_handles_cpp_sample_shallow() -> anyhow::Result<()> { - let temp = write_cpp_sample()?; - - let options = IndentationArgs { - include_siblings: false, - anchor_line: Some(18), - max_levels: 1, - ..Default::default() - }; - - let lines = read_block(temp.path(), 18, 200, options).await?; - assert_eq!( - lines, - vec![ - "L15: switch (mode_) {".to_string(), - "L16: case Mode::Fast:".to_string(), - "L17: return fast();".to_string(), - "L18: case Mode::Slow:".to_string(), - "L19: return slow();".to_string(), - "L20: default:".to_string(), - "L21: return fallback();".to_string(), - "L22: }".to_string(), - ] - ); - Ok(()) - } - - #[tokio::test] - async fn indentation_mode_handles_cpp_sample() -> anyhow::Result<()> { - let temp = write_cpp_sample()?; - - let options = IndentationArgs { - include_siblings: false, - anchor_line: Some(18), - max_levels: 2, - ..Default::default() - }; - - let lines = read_block(temp.path(), 18, 200, options).await?; - assert_eq!( - lines, - vec![ - "L13: // Run the code".to_string(), - "L14: int run() const {".to_string(), - "L15: switch (mode_) {".to_string(), - "L16: case Mode::Fast:".to_string(), - "L17: return fast();".to_string(), - "L18: case Mode::Slow:".to_string(), - "L19: return slow();".to_string(), - "L20: default:".to_string(), - "L21: return fallback();".to_string(), - "L22: }".to_string(), - "L23: }".to_string(), - ] - ); - Ok(()) - } - - #[tokio::test] - async fn indentation_mode_handles_cpp_sample_no_headers() -> anyhow::Result<()> { - let temp = write_cpp_sample()?; - - let options = IndentationArgs { - include_siblings: false, - include_header: false, - anchor_line: Some(18), - max_levels: 2, - ..Default::default() - }; - - let lines = read_block(temp.path(), 18, 200, options).await?; - assert_eq!( - lines, - vec![ - "L14: int run() const {".to_string(), - "L15: switch (mode_) {".to_string(), - "L16: case Mode::Fast:".to_string(), - "L17: return fast();".to_string(), - "L18: case Mode::Slow:".to_string(), - "L19: return slow();".to_string(), - "L20: default:".to_string(), - "L21: return fallback();".to_string(), - "L22: }".to_string(), - "L23: }".to_string(), - ] - ); - Ok(()) - } - - #[tokio::test] - async fn indentation_mode_handles_cpp_sample_siblings() -> anyhow::Result<()> { - let temp = write_cpp_sample()?; - - let options = IndentationArgs { - include_siblings: true, - include_header: false, - anchor_line: Some(18), - max_levels: 2, - ..Default::default() - }; - - let lines = read_block(temp.path(), 18, 200, options).await?; - assert_eq!( - lines, - vec![ - "L7: void setup() {".to_string(), - "L8: if (enabled_) {".to_string(), - "L9: init();".to_string(), - "L10: }".to_string(), - "L11: }".to_string(), - "L12: ".to_string(), - "L13: // Run the code".to_string(), - "L14: int run() const {".to_string(), - "L15: switch (mode_) {".to_string(), - "L16: case Mode::Fast:".to_string(), - "L17: return fast();".to_string(), - "L18: case Mode::Slow:".to_string(), - "L19: return slow();".to_string(), - "L20: default:".to_string(), - "L21: return fallback();".to_string(), - "L22: }".to_string(), - "L23: }".to_string(), - ] - ); - Ok(()) - } -} +#[path = "read_file_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/read_file_tests.rs b/codex-rs/core/src/tools/handlers/read_file_tests.rs new file mode 100644 index 0000000000..3921a98826 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/read_file_tests.rs @@ -0,0 +1,503 @@ +use super::indentation::read_block; +use super::slice::read; +use super::*; +use pretty_assertions::assert_eq; +use tempfile::NamedTempFile; + +#[tokio::test] +async fn reads_requested_range() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "alpha +beta +gamma +" + )?; + + let lines = read(temp.path(), 2, 2).await?; + assert_eq!(lines, vec!["L2: beta".to_string(), "L3: gamma".to_string()]); + Ok(()) +} + +#[tokio::test] +async fn errors_when_offset_exceeds_length() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + writeln!(temp, "only")?; + + let err = read(temp.path(), 3, 1) + .await + .expect_err("offset exceeds length"); + assert_eq!( + err, + FunctionCallError::RespondToModel("offset exceeds file length".to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn reads_non_utf8_lines() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + temp.as_file_mut().write_all(b"\xff\xfe\nplain\n")?; + + let lines = read(temp.path(), 1, 2).await?; + let expected_first = format!("L1: {}{}", '\u{FFFD}', '\u{FFFD}'); + assert_eq!(lines, vec![expected_first, "L2: plain".to_string()]); + Ok(()) +} + +#[tokio::test] +async fn trims_crlf_endings() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!(temp, "one\r\ntwo\r\n")?; + + let lines = read(temp.path(), 1, 2).await?; + assert_eq!(lines, vec!["L1: one".to_string(), "L2: two".to_string()]); + Ok(()) +} + +#[tokio::test] +async fn respects_limit_even_with_more_lines() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "first +second +third +" + )?; + + let lines = read(temp.path(), 1, 2).await?; + assert_eq!( + lines, + vec!["L1: first".to_string(), "L2: second".to_string()] + ); + Ok(()) +} + +#[tokio::test] +async fn truncates_lines_longer_than_max_length() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + let long_line = "x".repeat(MAX_LINE_LENGTH + 50); + writeln!(temp, "{long_line}")?; + + let lines = read(temp.path(), 1, 1).await?; + let expected = "x".repeat(MAX_LINE_LENGTH); + assert_eq!(lines, vec![format!("L1: {expected}")]); + Ok(()) +} + +#[tokio::test] +async fn indentation_mode_captures_block() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "fn outer() {{ + if cond {{ + inner(); + }} + tail(); +}} +" + )?; + + let options = IndentationArgs { + anchor_line: Some(3), + include_siblings: false, + max_levels: 1, + ..Default::default() + }; + + let lines = read_block(temp.path(), 3, 10, options).await?; + + assert_eq!( + lines, + vec![ + "L2: if cond {".to_string(), + "L3: inner();".to_string(), + "L4: }".to_string() + ] + ); + Ok(()) +} + +#[tokio::test] +async fn indentation_mode_expands_parents() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "mod root {{ + fn outer() {{ + if cond {{ + inner(); + }} + }} +}} +" + )?; + + let mut options = IndentationArgs { + anchor_line: Some(4), + max_levels: 2, + ..Default::default() + }; + + let lines = read_block(temp.path(), 4, 50, options.clone()).await?; + assert_eq!( + lines, + vec![ + "L2: fn outer() {".to_string(), + "L3: if cond {".to_string(), + "L4: inner();".to_string(), + "L5: }".to_string(), + "L6: }".to_string(), + ] + ); + + options.max_levels = 3; + let expanded = read_block(temp.path(), 4, 50, options).await?; + assert_eq!( + expanded, + vec![ + "L1: mod root {".to_string(), + "L2: fn outer() {".to_string(), + "L3: if cond {".to_string(), + "L4: inner();".to_string(), + "L5: }".to_string(), + "L6: }".to_string(), + "L7: }".to_string(), + ] + ); + Ok(()) +} + +#[tokio::test] +async fn indentation_mode_respects_sibling_flag() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "fn wrapper() {{ + if first {{ + do_first(); + }} + if second {{ + do_second(); + }} +}} +" + )?; + + let mut options = IndentationArgs { + anchor_line: Some(3), + include_siblings: false, + max_levels: 1, + ..Default::default() + }; + + let lines = read_block(temp.path(), 3, 50, options.clone()).await?; + assert_eq!( + lines, + vec![ + "L2: if first {".to_string(), + "L3: do_first();".to_string(), + "L4: }".to_string(), + ] + ); + + options.include_siblings = true; + let with_siblings = read_block(temp.path(), 3, 50, options).await?; + assert_eq!( + with_siblings, + vec![ + "L2: if first {".to_string(), + "L3: do_first();".to_string(), + "L4: }".to_string(), + "L5: if second {".to_string(), + "L6: do_second();".to_string(), + "L7: }".to_string(), + ] + ); + Ok(()) +} + +#[tokio::test] +async fn indentation_mode_handles_python_sample() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "class Foo: + def __init__(self, size): + self.size = size + def double(self, value): + if value is None: + return 0 + result = value * self.size + return result +class Bar: + def compute(self): + helper = Foo(2) + return helper.double(5) +" + )?; + + let options = IndentationArgs { + anchor_line: Some(7), + include_siblings: true, + max_levels: 1, + ..Default::default() + }; + + let lines = read_block(temp.path(), 1, 200, options).await?; + assert_eq!( + lines, + vec![ + "L2: def __init__(self, size):".to_string(), + "L3: self.size = size".to_string(), + "L4: def double(self, value):".to_string(), + "L5: if value is None:".to_string(), + "L6: return 0".to_string(), + "L7: result = value * self.size".to_string(), + "L8: return result".to_string(), + ] + ); + Ok(()) +} + +#[tokio::test] +#[ignore] +async fn indentation_mode_handles_javascript_sample() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "export function makeThing() {{ + const cache = new Map(); + function ensure(key) {{ + if (!cache.has(key)) {{ + cache.set(key, []); + }} + return cache.get(key); + }} + const handlers = {{ + init() {{ + console.log(\"init\"); + }}, + run() {{ + if (Math.random() > 0.5) {{ + return \"heads\"; + }} + return \"tails\"; + }}, + }}; + return {{ cache, handlers }}; +}} +export function other() {{ + return makeThing(); +}} +" + )?; + + let options = IndentationArgs { + anchor_line: Some(15), + max_levels: 1, + ..Default::default() + }; + + let lines = read_block(temp.path(), 15, 200, options).await?; + assert_eq!( + lines, + vec![ + "L10: init() {".to_string(), + "L11: console.log(\"init\");".to_string(), + "L12: },".to_string(), + "L13: run() {".to_string(), + "L14: if (Math.random() > 0.5) {".to_string(), + "L15: return \"heads\";".to_string(), + "L16: }".to_string(), + "L17: return \"tails\";".to_string(), + "L18: },".to_string(), + ] + ); + Ok(()) +} + +fn write_cpp_sample() -> anyhow::Result { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "#include +#include + +namespace sample {{ +class Runner {{ +public: + void setup() {{ + if (enabled_) {{ + init(); + }} + }} + + // Run the code + int run() const {{ + switch (mode_) {{ + case Mode::Fast: + return fast(); + case Mode::Slow: + return slow(); + default: + return fallback(); + }} + }} + +private: + bool enabled_ = false; + Mode mode_ = Mode::Fast; + + int fast() const {{ + return 1; + }} +}}; +}} // namespace sample +" + )?; + Ok(temp) +} + +#[tokio::test] +async fn indentation_mode_handles_cpp_sample_shallow() -> anyhow::Result<()> { + let temp = write_cpp_sample()?; + + let options = IndentationArgs { + include_siblings: false, + anchor_line: Some(18), + max_levels: 1, + ..Default::default() + }; + + let lines = read_block(temp.path(), 18, 200, options).await?; + assert_eq!( + lines, + vec![ + "L15: switch (mode_) {".to_string(), + "L16: case Mode::Fast:".to_string(), + "L17: return fast();".to_string(), + "L18: case Mode::Slow:".to_string(), + "L19: return slow();".to_string(), + "L20: default:".to_string(), + "L21: return fallback();".to_string(), + "L22: }".to_string(), + ] + ); + Ok(()) +} + +#[tokio::test] +async fn indentation_mode_handles_cpp_sample() -> anyhow::Result<()> { + let temp = write_cpp_sample()?; + + let options = IndentationArgs { + include_siblings: false, + anchor_line: Some(18), + max_levels: 2, + ..Default::default() + }; + + let lines = read_block(temp.path(), 18, 200, options).await?; + assert_eq!( + lines, + vec![ + "L13: // Run the code".to_string(), + "L14: int run() const {".to_string(), + "L15: switch (mode_) {".to_string(), + "L16: case Mode::Fast:".to_string(), + "L17: return fast();".to_string(), + "L18: case Mode::Slow:".to_string(), + "L19: return slow();".to_string(), + "L20: default:".to_string(), + "L21: return fallback();".to_string(), + "L22: }".to_string(), + "L23: }".to_string(), + ] + ); + Ok(()) +} + +#[tokio::test] +async fn indentation_mode_handles_cpp_sample_no_headers() -> anyhow::Result<()> { + let temp = write_cpp_sample()?; + + let options = IndentationArgs { + include_siblings: false, + include_header: false, + anchor_line: Some(18), + max_levels: 2, + ..Default::default() + }; + + let lines = read_block(temp.path(), 18, 200, options).await?; + assert_eq!( + lines, + vec![ + "L14: int run() const {".to_string(), + "L15: switch (mode_) {".to_string(), + "L16: case Mode::Fast:".to_string(), + "L17: return fast();".to_string(), + "L18: case Mode::Slow:".to_string(), + "L19: return slow();".to_string(), + "L20: default:".to_string(), + "L21: return fallback();".to_string(), + "L22: }".to_string(), + "L23: }".to_string(), + ] + ); + Ok(()) +} + +#[tokio::test] +async fn indentation_mode_handles_cpp_sample_siblings() -> anyhow::Result<()> { + let temp = write_cpp_sample()?; + + let options = IndentationArgs { + include_siblings: true, + include_header: false, + anchor_line: Some(18), + max_levels: 2, + ..Default::default() + }; + + let lines = read_block(temp.path(), 18, 200, options).await?; + assert_eq!( + lines, + vec![ + "L7: void setup() {".to_string(), + "L8: if (enabled_) {".to_string(), + "L9: init();".to_string(), + "L10: }".to_string(), + "L11: }".to_string(), + "L12: ".to_string(), + "L13: // Run the code".to_string(), + "L14: int run() const {".to_string(), + "L15: switch (mode_) {".to_string(), + "L16: case Mode::Fast:".to_string(), + "L17: return fast();".to_string(), + "L18: case Mode::Slow:".to_string(), + "L19: return slow();".to_string(), + "L20: default:".to_string(), + "L21: return fallback();".to_string(), + "L22: }".to_string(), + "L23: }".to_string(), + ] + ); + Ok(()) +} diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index 77def0b682..4d95a2c20d 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -121,51 +121,5 @@ impl ToolHandler for RequestUserInputHandler { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn request_user_input_mode_availability_defaults_to_plan_only() { - assert!(ModeKind::Plan.allows_request_user_input()); - assert!(!ModeKind::Default.allows_request_user_input()); - assert!(!ModeKind::Execute.allows_request_user_input()); - assert!(!ModeKind::PairProgramming.allows_request_user_input()); - } - - #[test] - fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() { - assert_eq!( - request_user_input_unavailable_message(ModeKind::Plan, false), - None - ); - assert_eq!( - request_user_input_unavailable_message(ModeKind::Default, false), - Some("request_user_input is unavailable in Default mode".to_string()) - ); - assert_eq!( - request_user_input_unavailable_message(ModeKind::Default, true), - None - ); - assert_eq!( - request_user_input_unavailable_message(ModeKind::Execute, false), - Some("request_user_input is unavailable in Execute mode".to_string()) - ); - assert_eq!( - request_user_input_unavailable_message(ModeKind::PairProgramming, false), - Some("request_user_input is unavailable in Pair Programming mode".to_string()) - ); - } - - #[test] - fn request_user_input_tool_description_mentions_available_modes() { - assert_eq!( - request_user_input_tool_description(false), - "Request user input for one to three short questions and wait for the response. This tool is only available in Plan mode.".to_string() - ); - assert_eq!( - request_user_input_tool_description(true), - "Request user input for one to three short questions and wait for the response. This tool is only available in Default or Plan mode.".to_string() - ); - } -} +#[path = "request_user_input_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs new file mode 100644 index 0000000000..f4df3c43c0 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -0,0 +1,46 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn request_user_input_mode_availability_defaults_to_plan_only() { + assert!(ModeKind::Plan.allows_request_user_input()); + assert!(!ModeKind::Default.allows_request_user_input()); + assert!(!ModeKind::Execute.allows_request_user_input()); + assert!(!ModeKind::PairProgramming.allows_request_user_input()); +} + +#[test] +fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() { + assert_eq!( + request_user_input_unavailable_message(ModeKind::Plan, false), + None + ); + assert_eq!( + request_user_input_unavailable_message(ModeKind::Default, false), + Some("request_user_input is unavailable in Default mode".to_string()) + ); + assert_eq!( + request_user_input_unavailable_message(ModeKind::Default, true), + None + ); + assert_eq!( + request_user_input_unavailable_message(ModeKind::Execute, false), + Some("request_user_input is unavailable in Execute mode".to_string()) + ); + assert_eq!( + request_user_input_unavailable_message(ModeKind::PairProgramming, false), + Some("request_user_input is unavailable in Pair Programming mode".to_string()) + ); +} + +#[test] +fn request_user_input_tool_description_mentions_available_modes() { + assert_eq!( + request_user_input_tool_description(false), + "Request user input for one to three short questions and wait for the response. This tool is only available in Plan mode.".to_string() + ); + assert_eq!( + request_user_input_tool_description(true), + "Request user input for one to three short questions and wait for the response. This tool is only available in Default or Plan mode.".to_string() + ); +} diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs deleted file mode 100644 index a038fd5edb..0000000000 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ /dev/null @@ -1,349 +0,0 @@ -use async_trait::async_trait; -use bm25::Document; -use bm25::Language; -use bm25::SearchEngineBuilder; -use codex_app_server_protocol::AppInfo; -use serde::Deserialize; -use serde_json::json; -use std::collections::HashMap; -use std::collections::HashSet; - -use crate::connectors; -use crate::function_tool::FunctionCallError; -use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; -use crate::mcp_connection_manager::ToolInfo; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct SearchToolBm25Handler; - -pub(crate) const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25"; -pub(crate) const DEFAULT_LIMIT: usize = 8; - -fn default_limit() -> usize { - DEFAULT_LIMIT -} - -#[derive(Deserialize)] -struct SearchToolBm25Args { - query: String, - #[serde(default = "default_limit")] - limit: usize, -} - -#[derive(Clone)] -struct ToolEntry { - name: String, - server_name: String, - title: Option, - description: Option, - connector_name: Option, - input_keys: Vec, - search_text: String, -} - -impl ToolEntry { - fn new(name: String, info: ToolInfo) -> Self { - let input_keys = info - .tool - .input_schema - .get("properties") - .and_then(serde_json::Value::as_object) - .map(|map| map.keys().cloned().collect::>()) - .unwrap_or_default(); - let search_text = build_search_text(&name, &info, &input_keys); - Self { - name, - server_name: info.server_name, - title: info.tool.title, - description: info - .tool - .description - .map(|description| description.to_string()), - connector_name: info.connector_name, - input_keys, - search_text, - } - } -} - -#[async_trait] -impl ToolHandler for SearchToolBm25Handler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - payload, - session, - turn, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::Fatal(format!( - "{SEARCH_TOOL_BM25_TOOL_NAME} handler received unsupported payload" - ))); - } - }; - - let args: SearchToolBm25Args = parse_arguments(&arguments)?; - let query = args.query.trim(); - if query.is_empty() { - return Err(FunctionCallError::RespondToModel( - "query must not be empty".to_string(), - )); - } - - if args.limit == 0 { - return Err(FunctionCallError::RespondToModel( - "limit must be greater than zero".to_string(), - )); - } - - let limit = args.limit; - - let mcp_tools = session - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await; - - let connectors = connectors::with_app_enabled_state( - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - &turn.config, - ); - let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors); - let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config); - - let mut entries: Vec = mcp_tools - .into_iter() - .map(|(name, info)| ToolEntry::new(name, info)) - .collect(); - entries.sort_by(|a, b| a.name.cmp(&b.name)); - - if entries.is_empty() { - let active_selected_tools = session.get_mcp_tool_selection().await.unwrap_or_default(); - let content = json!({ - "query": query, - "total_tools": 0, - "active_selected_tools": active_selected_tools, - "tools": [], - }) - .to_string(); - return Ok(FunctionToolOutput::from_text(content, Some(true))); - } - - let documents: Vec> = entries - .iter() - .enumerate() - .map(|(idx, entry)| Document::new(idx, entry.search_text.clone())) - .collect(); - let search_engine = - SearchEngineBuilder::::with_documents(Language::English, documents).build(); - let results = search_engine.search(query, limit); - - let mut selected_tools = Vec::new(); - let mut result_payloads = Vec::new(); - for result in results { - let Some(entry) = entries.get(result.document.id) else { - continue; - }; - selected_tools.push(entry.name.clone()); - result_payloads.push(json!({ - "name": entry.name.clone(), - "server": entry.server_name.clone(), - "title": entry.title.clone(), - "description": entry.description.clone(), - "connector_name": entry.connector_name.clone(), - "input_keys": entry.input_keys.clone(), - "score": result.score, - })); - } - - let active_selected_tools = session.merge_mcp_tool_selection(selected_tools).await; - - let content = json!({ - "query": query, - "total_tools": entries.len(), - "active_selected_tools": active_selected_tools, - "tools": result_payloads, - }) - .to_string(); - - Ok(FunctionToolOutput::from_text(content, Some(true))) - } -} - -fn filter_codex_apps_mcp_tools( - mut mcp_tools: HashMap, - connectors: &[AppInfo], -) -> HashMap { - let enabled_connectors: HashSet<&str> = connectors - .iter() - .filter(|connector| connector.is_enabled) - .map(|connector| connector.id.as_str()) - .collect(); - - mcp_tools.retain(|_, tool| { - if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { - return false; - } - - tool.connector_id - .as_deref() - .is_some_and(|connector_id| enabled_connectors.contains(connector_id)) - }); - mcp_tools -} - -fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> String { - let mut parts = vec![ - name.to_string(), - info.tool_name.clone(), - info.server_name.clone(), - ]; - - if let Some(title) = info.tool.title.as_deref() - && !title.trim().is_empty() - { - parts.push(title.to_string()); - } - - if let Some(description) = info.tool.description.as_deref() - && !description.trim().is_empty() - { - parts.push(description.to_string()); - } - - if let Some(connector_name) = info.connector_name.as_deref() - && !connector_name.trim().is_empty() - { - parts.push(connector_name.to_string()); - } - - if !input_keys.is_empty() { - parts.extend(input_keys.iter().cloned()); - } - - parts.join(" ") -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_app_server_protocol::AppInfo; - use pretty_assertions::assert_eq; - use rmcp::model::JsonObject; - use rmcp::model::Tool; - use std::sync::Arc; - - fn make_connector(id: &str, enabled: bool) -> AppInfo { - AppInfo { - id: id.to_string(), - name: id.to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: true, - is_enabled: enabled, - plugin_display_names: Vec::new(), - } - } - - fn make_tool( - qualified_name: &str, - server_name: &str, - tool_name: &str, - connector_id: Option<&str>, - ) -> (String, ToolInfo) { - ( - qualified_name.to_string(), - ToolInfo { - server_name: server_name.to_string(), - tool_name: tool_name.to_string(), - tool: Tool { - name: tool_name.to_string().into(), - title: None, - description: Some(format!("Test tool: {tool_name}").into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, - connector_id: connector_id.map(str::to_string), - connector_name: connector_id.map(str::to_string), - plugin_display_names: Vec::new(), - }, - ) - } - - #[test] - fn filter_codex_apps_mcp_tools_keeps_enabled_apps_only() { - let mcp_tools = HashMap::from([ - make_tool( - "mcp__codex_apps__calendar_create_event", - CODEX_APPS_MCP_SERVER_NAME, - "calendar_create_event", - Some("calendar"), - ), - make_tool( - "mcp__codex_apps__drive_search", - CODEX_APPS_MCP_SERVER_NAME, - "drive_search", - Some("drive"), - ), - make_tool("mcp__rmcp__echo", "rmcp", "echo", None), - ]); - let connectors = vec![ - make_connector("calendar", false), - make_connector("drive", true), - ]; - - let mut filtered: Vec = filter_codex_apps_mcp_tools(mcp_tools, &connectors) - .into_keys() - .collect(); - filtered.sort(); - - assert_eq!(filtered, vec!["mcp__codex_apps__drive_search".to_string()]); - } - - #[test] - fn filter_codex_apps_mcp_tools_drops_apps_without_connector_id() { - let mcp_tools = HashMap::from([ - make_tool( - "mcp__codex_apps__unknown", - CODEX_APPS_MCP_SERVER_NAME, - "unknown", - None, - ), - make_tool("mcp__rmcp__echo", "rmcp", "echo", None), - ]); - - let mut filtered: Vec = - filter_codex_apps_mcp_tools(mcp_tools, &[make_connector("calendar", true)]) - .into_keys() - .collect(); - filtered.sort(); - - assert_eq!(filtered, Vec::::new()); - } -} diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 8c8d668f85..01d7f1b6e9 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -403,6 +403,7 @@ impl ShellHandler { command: &exec_params.command, approval_policy: turn.approval_policy.value(), sandbox_policy: turn.sandbox_policy.get(), + file_system_sandbox_policy: &turn.file_system_sandbox_policy, sandbox_permissions: if effective_additional_permissions.permissions_preapproved { codex_protocol::models::SandboxPermissions::UseDefault } else { @@ -421,6 +422,9 @@ impl ShellHandler { network: exec_params.network.clone(), sandbox_permissions: effective_additional_permissions.sandbox_permissions, additional_permissions: normalized_additional_permissions, + #[cfg(unix)] + additional_permissions_preapproved: effective_additional_permissions + .permissions_preapproved, justification: exec_params.justification.clone(), exec_approval_requirement, }; @@ -457,185 +461,5 @@ impl ShellHandler { } #[cfg(test)] -mod tests { - use std::path::PathBuf; - use std::sync::Arc; - - use codex_protocol::models::ShellCommandToolCallParams; - use pretty_assertions::assert_eq; - - use crate::codex::make_session_and_context; - use crate::exec_env::create_env; - use crate::is_safe_command::is_known_safe_command; - use crate::powershell::try_find_powershell_executable_blocking; - use crate::powershell::try_find_pwsh_executable_blocking; - use crate::sandboxing::SandboxPermissions; - use crate::shell::Shell; - use crate::shell::ShellType; - use crate::shell_snapshot::ShellSnapshot; - use crate::tools::handlers::ShellCommandHandler; - use tokio::sync::watch; - - /// The logic for is_known_safe_command() has heuristics for known shells, - /// so we must ensure the commands generated by [ShellCommandHandler] can be - /// recognized as safe if the `command` is safe. - #[test] - fn commands_generated_by_shell_command_handler_can_be_matched_by_is_known_safe_command() { - let bash_shell = Shell { - shell_type: ShellType::Bash, - shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }; - assert_safe(&bash_shell, "ls -la"); - - let zsh_shell = Shell { - shell_type: ShellType::Zsh, - shell_path: PathBuf::from("/bin/zsh"), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }; - assert_safe(&zsh_shell, "ls -la"); - - if let Some(path) = try_find_powershell_executable_blocking() { - let powershell = Shell { - shell_type: ShellType::PowerShell, - shell_path: path.to_path_buf(), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }; - assert_safe(&powershell, "ls -Name"); - } - - if let Some(path) = try_find_pwsh_executable_blocking() { - let pwsh = Shell { - shell_type: ShellType::PowerShell, - shell_path: path.to_path_buf(), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }; - assert_safe(&pwsh, "ls -Name"); - } - } - - fn assert_safe(shell: &Shell, command: &str) { - assert!(is_known_safe_command( - &shell.derive_exec_args(command, /* use_login_shell */ true) - )); - assert!(is_known_safe_command( - &shell.derive_exec_args(command, /* use_login_shell */ false) - )); - } - - #[tokio::test] - async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_context() { - let (session, turn_context) = make_session_and_context().await; - - let command = "echo hello".to_string(); - let workdir = Some("subdir".to_string()); - let login = None; - let timeout_ms = Some(1234); - let sandbox_permissions = SandboxPermissions::RequireEscalated; - let justification = Some("because tests".to_string()); - - let expected_command = session.user_shell().derive_exec_args(&command, true); - let expected_cwd = turn_context.resolve_path(workdir.clone()); - let expected_env = create_env( - &turn_context.shell_environment_policy, - Some(session.conversation_id), - ); - - let params = ShellCommandToolCallParams { - command, - workdir, - login, - timeout_ms, - sandbox_permissions: Some(sandbox_permissions), - additional_permissions: None, - prefix_rule: None, - justification: justification.clone(), - }; - - let exec_params = ShellCommandHandler::to_exec_params( - ¶ms, - &session, - &turn_context, - session.conversation_id, - true, - ) - .expect("login shells should be allowed"); - - // ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields. - assert_eq!(exec_params.command, expected_command); - assert_eq!(exec_params.cwd, expected_cwd); - assert_eq!(exec_params.env, expected_env); - assert_eq!(exec_params.network, turn_context.network); - assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); - assert_eq!(exec_params.sandbox_permissions, sandbox_permissions); - assert_eq!(exec_params.justification, justification); - assert_eq!(exec_params.arg0, None); - } - - #[test] - fn shell_command_handler_respects_explicit_login_flag() { - let (_tx, shell_snapshot) = watch::channel(Some(Arc::new(ShellSnapshot { - path: PathBuf::from("/tmp/snapshot.sh"), - cwd: PathBuf::from("/tmp"), - }))); - let shell = Shell { - shell_type: ShellType::Bash, - shell_path: PathBuf::from("/bin/bash"), - shell_snapshot, - }; - - let login_command = ShellCommandHandler::base_command(&shell, "echo login shell", true); - assert_eq!( - login_command, - shell.derive_exec_args("echo login shell", true) - ); - - let non_login_command = - ShellCommandHandler::base_command(&shell, "echo non login shell", false); - assert_eq!( - non_login_command, - shell.derive_exec_args("echo non login shell", false) - ); - } - - #[tokio::test] - async fn shell_command_handler_defaults_to_non_login_when_disallowed() { - let (session, turn_context) = make_session_and_context().await; - let params = ShellCommandToolCallParams { - command: "echo hello".to_string(), - workdir: None, - login: None, - timeout_ms: None, - sandbox_permissions: None, - additional_permissions: None, - prefix_rule: None, - justification: None, - }; - - let exec_params = ShellCommandHandler::to_exec_params( - ¶ms, - &session, - &turn_context, - session.conversation_id, - false, - ) - .expect("non-login shells should still be allowed"); - - assert_eq!( - exec_params.command, - session.user_shell().derive_exec_args("echo hello", false) - ); - } - - #[test] - fn shell_command_handler_rejects_login_when_disallowed() { - let err = ShellCommandHandler::resolve_use_login_shell(Some(true), false) - .expect_err("explicit login should be rejected"); - - assert!( - err.to_string() - .contains("login shell is disabled by config"), - "unexpected error: {err}" - ); - } -} +#[path = "shell_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs new file mode 100644 index 0000000000..b69f3be230 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -0,0 +1,180 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use codex_protocol::models::ShellCommandToolCallParams; +use pretty_assertions::assert_eq; + +use crate::codex::make_session_and_context; +use crate::exec_env::create_env; +use crate::is_safe_command::is_known_safe_command; +use crate::powershell::try_find_powershell_executable_blocking; +use crate::powershell::try_find_pwsh_executable_blocking; +use crate::sandboxing::SandboxPermissions; +use crate::shell::Shell; +use crate::shell::ShellType; +use crate::shell_snapshot::ShellSnapshot; +use crate::tools::handlers::ShellCommandHandler; +use tokio::sync::watch; + +/// The logic for is_known_safe_command() has heuristics for known shells, +/// so we must ensure the commands generated by [ShellCommandHandler] can be +/// recognized as safe if the `command` is safe. +#[test] +fn commands_generated_by_shell_command_handler_can_be_matched_by_is_known_safe_command() { + let bash_shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }; + assert_safe(&bash_shell, "ls -la"); + + let zsh_shell = Shell { + shell_type: ShellType::Zsh, + shell_path: PathBuf::from("/bin/zsh"), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }; + assert_safe(&zsh_shell, "ls -la"); + + if let Some(path) = try_find_powershell_executable_blocking() { + let powershell = Shell { + shell_type: ShellType::PowerShell, + shell_path: path.to_path_buf(), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }; + assert_safe(&powershell, "ls -Name"); + } + + if let Some(path) = try_find_pwsh_executable_blocking() { + let pwsh = Shell { + shell_type: ShellType::PowerShell, + shell_path: path.to_path_buf(), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }; + assert_safe(&pwsh, "ls -Name"); + } +} + +fn assert_safe(shell: &Shell, command: &str) { + assert!(is_known_safe_command( + &shell.derive_exec_args(command, /* use_login_shell */ true) + )); + assert!(is_known_safe_command( + &shell.derive_exec_args(command, /* use_login_shell */ false) + )); +} + +#[tokio::test] +async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_context() { + let (session, turn_context) = make_session_and_context().await; + + let command = "echo hello".to_string(); + let workdir = Some("subdir".to_string()); + let login = None; + let timeout_ms = Some(1234); + let sandbox_permissions = SandboxPermissions::RequireEscalated; + let justification = Some("because tests".to_string()); + + let expected_command = session.user_shell().derive_exec_args(&command, true); + let expected_cwd = turn_context.resolve_path(workdir.clone()); + let expected_env = create_env( + &turn_context.shell_environment_policy, + Some(session.conversation_id), + ); + + let params = ShellCommandToolCallParams { + command, + workdir, + login, + timeout_ms, + sandbox_permissions: Some(sandbox_permissions), + additional_permissions: None, + prefix_rule: None, + justification: justification.clone(), + }; + + let exec_params = ShellCommandHandler::to_exec_params( + ¶ms, + &session, + &turn_context, + session.conversation_id, + true, + ) + .expect("login shells should be allowed"); + + // ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields. + assert_eq!(exec_params.command, expected_command); + assert_eq!(exec_params.cwd, expected_cwd); + assert_eq!(exec_params.env, expected_env); + assert_eq!(exec_params.network, turn_context.network); + assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); + assert_eq!(exec_params.sandbox_permissions, sandbox_permissions); + assert_eq!(exec_params.justification, justification); + assert_eq!(exec_params.arg0, None); +} + +#[test] +fn shell_command_handler_respects_explicit_login_flag() { + let (_tx, shell_snapshot) = watch::channel(Some(Arc::new(ShellSnapshot { + path: PathBuf::from("/tmp/snapshot.sh"), + cwd: PathBuf::from("/tmp"), + }))); + let shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot, + }; + + let login_command = ShellCommandHandler::base_command(&shell, "echo login shell", true); + assert_eq!( + login_command, + shell.derive_exec_args("echo login shell", true) + ); + + let non_login_command = + ShellCommandHandler::base_command(&shell, "echo non login shell", false); + assert_eq!( + non_login_command, + shell.derive_exec_args("echo non login shell", false) + ); +} + +#[tokio::test] +async fn shell_command_handler_defaults_to_non_login_when_disallowed() { + let (session, turn_context) = make_session_and_context().await; + let params = ShellCommandToolCallParams { + command: "echo hello".to_string(), + workdir: None, + login: None, + timeout_ms: None, + sandbox_permissions: None, + additional_permissions: None, + prefix_rule: None, + justification: None, + }; + + let exec_params = ShellCommandHandler::to_exec_params( + ¶ms, + &session, + &turn_context, + session.conversation_id, + false, + ) + .expect("non-login shells should still be allowed"); + + assert_eq!( + exec_params.command, + session.user_shell().derive_exec_args("echo hello", false) + ); +} + +#[test] +fn shell_command_handler_rejects_login_when_disallowed() { + let err = ShellCommandHandler::resolve_use_login_shell(Some(true), false) + .expect_err("explicit login should be rejected"); + + assert!( + err.to_string() + .contains("login shell is disabled by config"), + "unexpected error: {err}" + ); +} diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs new file mode 100644 index 0000000000..2005c7d2f1 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -0,0 +1,192 @@ +use crate::client_common::tools::ResponsesApiNamespace; +use crate::client_common::tools::ResponsesApiNamespaceTool; +use crate::client_common::tools::ToolSearchOutputTool; +use crate::function_tool::FunctionCallError; +use crate::mcp_connection_manager::ToolInfo; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::context::ToolSearchOutput; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use crate::tools::spec::mcp_tool_to_deferred_openai_tool; +use async_trait::async_trait; +use bm25::Document; +use bm25::Language; +use bm25::SearchEngineBuilder; +use std::collections::BTreeMap; +use std::collections::HashMap; + +#[cfg(test)] +use crate::client_common::tools::ResponsesApiTool; + +pub struct ToolSearchHandler { + tools: HashMap, +} + +pub(crate) const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; +pub(crate) const DEFAULT_LIMIT: usize = 8; + +impl ToolSearchHandler { + pub fn new(tools: HashMap) -> Self { + Self { tools } + } +} + +#[async_trait] +impl ToolHandler for ToolSearchHandler { + type Output = ToolSearchOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result { + let ToolInvocation { payload, .. } = invocation; + + let args = match payload { + ToolPayload::ToolSearch { arguments } => arguments, + _ => { + return Err(FunctionCallError::Fatal(format!( + "{TOOL_SEARCH_TOOL_NAME} handler received unsupported payload" + ))); + } + }; + + let query = args.query.trim(); + if query.is_empty() { + return Err(FunctionCallError::RespondToModel( + "query must not be empty".to_string(), + )); + } + let limit = args.limit.unwrap_or(DEFAULT_LIMIT); + + if limit == 0 { + return Err(FunctionCallError::RespondToModel( + "limit must be greater than zero".to_string(), + )); + } + + let mut entries: Vec<(String, ToolInfo)> = self.tools.clone().into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + if entries.is_empty() { + return Ok(ToolSearchOutput { tools: Vec::new() }); + } + + let documents: Vec> = entries + .iter() + .enumerate() + .map(|(idx, (name, info))| Document::new(idx, build_search_text(name, info))) + .collect(); + let search_engine = + SearchEngineBuilder::::with_documents(Language::English, documents).build(); + let results = search_engine.search(query, limit); + + let matched_entries = results + .into_iter() + .filter_map(|result| entries.get(result.document.id)) + .collect::>(); + let tools = serialize_tool_search_output_tools(&matched_entries).map_err(|err| { + FunctionCallError::Fatal(format!("failed to encode tool_search output: {err}")) + })?; + + Ok(ToolSearchOutput { tools }) + } +} + +fn serialize_tool_search_output_tools( + matched_entries: &[&(String, ToolInfo)], +) -> Result, serde_json::Error> { + let grouped: BTreeMap> = + matched_entries + .iter() + .fold(BTreeMap::new(), |mut acc, (_name, tool)| { + acc.entry(tool.tool_namespace.clone()) + .or_default() + .push(tool.clone()); + + acc + }); + + let mut results = Vec::with_capacity(grouped.len()); + for (namespace, tools) in grouped { + let Some(first_tool) = tools.first() else { + continue; + }; + + let description = first_tool.connector_description.clone().or_else(|| { + first_tool + .connector_name + .as_deref() + .map(str::trim) + .filter(|connector_name| !connector_name.is_empty()) + .map(|connector_name| format!("Tools for working with {connector_name}.")) + }); + + let tools = tools + .iter() + .map(|tool| { + mcp_tool_to_deferred_openai_tool(tool.tool_name.clone(), tool.tool.clone()) + .map(ResponsesApiNamespaceTool::Function) + }) + .collect::, _>>()?; + + results.push(ToolSearchOutputTool::Namespace(ResponsesApiNamespace { + name: namespace, + description: description.unwrap_or_default(), + tools, + })); + } + + Ok(results) +} + +fn build_search_text(name: &str, info: &ToolInfo) -> String { + let mut parts = vec![ + name.to_string(), + info.tool_name.clone(), + info.server_name.clone(), + ]; + + if let Some(title) = info.tool.title.as_deref() + && !title.trim().is_empty() + { + parts.push(title.to_string()); + } + + if let Some(description) = info.tool.description.as_deref() + && !description.trim().is_empty() + { + parts.push(description.to_string()); + } + + if let Some(connector_name) = info.connector_name.as_deref() + && !connector_name.trim().is_empty() + { + parts.push(connector_name.to_string()); + } + + if let Some(connector_description) = info.connector_description.as_deref() + && !connector_description.trim().is_empty() + { + parts.push(connector_description.to_string()); + } + + parts.extend( + info.tool + .input_schema + .get("properties") + .and_then(serde_json::Value::as_object) + .map(|map| map.keys().cloned().collect::>()) + .unwrap_or_default(), + ); + + parts.join(" ") +} + +#[cfg(test)] +#[path = "tool_search_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/tool_search_tests.rs b/codex-rs/core/src/tools/handlers/tool_search_tests.rs new file mode 100644 index 0000000000..fc7ef5970e --- /dev/null +++ b/codex-rs/core/src/tools/handlers/tool_search_tests.rs @@ -0,0 +1,198 @@ +use super::*; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use pretty_assertions::assert_eq; +use rmcp::model::JsonObject; +use rmcp::model::Tool; +use serde_json::json; +use std::sync::Arc; + +#[test] +fn serialize_tool_search_output_tools_groups_results_by_namespace() { + let entries = [ + ( + "mcp__codex_apps__calendar-create-event".to_string(), + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-create-event".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: Tool { + name: "calendar-create-event".to_string().into(), + title: None, + description: Some("Create a calendar event.".into()), + input_schema: Arc::new(JsonObject::from_iter([( + "type".to_string(), + json!("object"), + )])), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + plugin_display_names: Vec::new(), + connector_description: Some("Plan events".to_string()), + }, + ), + ( + "mcp__codex_apps__gmail-read-email".to_string(), + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-read-email".to_string(), + tool_namespace: "mcp__codex_apps__gmail".to_string(), + tool: Tool { + name: "gmail-read-email".to_string().into(), + title: None, + description: Some("Read an email.".into()), + input_schema: Arc::new(JsonObject::from_iter([( + "type".to_string(), + json!("object"), + )])), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: Some("gmail".to_string()), + connector_name: Some("Gmail".to_string()), + plugin_display_names: Vec::new(), + connector_description: Some("Read mail".to_string()), + }, + ), + ( + "mcp__codex_apps__calendar-list-events".to_string(), + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-list-events".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: Tool { + name: "calendar-list-events".to_string().into(), + title: None, + description: Some("List calendar events.".into()), + input_schema: Arc::new(JsonObject::from_iter([( + "type".to_string(), + json!("object"), + )])), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + plugin_display_names: Vec::new(), + connector_description: Some("Plan events".to_string()), + }, + ), + ]; + + let tools = serialize_tool_search_output_tools(&[&entries[0], &entries[1], &entries[2]]) + .expect("serialize tool search output"); + + assert_eq!( + tools, + vec![ + ToolSearchOutputTool::Namespace(ResponsesApiNamespace { + name: "mcp__codex_apps__calendar".to_string(), + description: "Plan events".to_string(), + tools: vec![ + ResponsesApiNamespaceTool::Function(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, + }), + ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "-list-events".to_string(), + description: "List calendar events.".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, + }), + ], + }), + ToolSearchOutputTool::Namespace(ResponsesApiNamespace { + name: "mcp__codex_apps__gmail".to_string(), + description: "Read mail".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "-read-email".to_string(), + description: "Read an email.".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, + })], + }) + ] + ); +} + +#[test] +fn serialize_tool_search_output_tools_falls_back_to_connector_name_description() { + let entries = [( + "mcp__codex_apps__gmail-batch-read-email".to_string(), + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-batch-read-email".to_string(), + tool_namespace: "mcp__codex_apps__gmail".to_string(), + tool: Tool { + name: "gmail-batch-read-email".to_string().into(), + title: None, + description: Some("Read multiple emails.".into()), + input_schema: Arc::new(JsonObject::from_iter([( + "type".to_string(), + json!("object"), + )])), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: Some("connector_gmail_456".to_string()), + connector_name: Some("Gmail".to_string()), + plugin_display_names: Vec::new(), + connector_description: None, + }, + )]; + + let tools = serialize_tool_search_output_tools(&[&entries[0]]).expect("serialize"); + + assert_eq!( + tools, + vec![ToolSearchOutputTool::Namespace(ResponsesApiNamespace { + name: "mcp__codex_apps__gmail".to_string(), + description: "Tools for working with Gmail.".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "-batch-read-email".to_string(), + description: "Read multiple emails.".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, + })], + })] + ); +} diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs new file mode 100644 index 0000000000..311f191bd0 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -0,0 +1,298 @@ +use std::collections::BTreeMap; +use std::collections::HashSet; + +use async_trait::async_trait; +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::McpElicitationObjectType; +use codex_app_server_protocol::McpElicitationSchema; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_rmcp_client::ElicitationAction; +use rmcp::model::RequestId; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use tracing::warn; + +use crate::connectors; +use crate::function_tool::FunctionCallError; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::discoverable::DiscoverableTool; +use crate::tools::discoverable::DiscoverableToolAction; +use crate::tools::discoverable::DiscoverableToolType; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +pub struct ToolSuggestHandler; + +pub(crate) const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest"; +const TOOL_SUGGEST_APPROVAL_KIND_VALUE: &str = "tool_suggestion"; + +#[derive(Debug, Deserialize)] +struct ToolSuggestArgs { + tool_type: DiscoverableToolType, + action_type: DiscoverableToolAction, + tool_id: String, + suggest_reason: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct ToolSuggestResult { + completed: bool, + user_confirmed: bool, + tool_type: DiscoverableToolType, + action_type: DiscoverableToolAction, + tool_id: String, + tool_name: String, + suggest_reason: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct ToolSuggestMeta<'a> { + codex_approval_kind: &'static str, + tool_type: DiscoverableToolType, + suggest_type: DiscoverableToolAction, + suggest_reason: &'a str, + tool_id: &'a str, + tool_name: &'a str, + install_url: &'a str, +} + +#[async_trait] +impl ToolHandler for ToolSuggestHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + payload, + session, + turn, + call_id, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::Fatal(format!( + "{TOOL_SUGGEST_TOOL_NAME} handler received unsupported payload" + ))); + } + }; + + let args: ToolSuggestArgs = parse_arguments(&arguments)?; + let suggest_reason = args.suggest_reason.trim(); + if suggest_reason.is_empty() { + return Err(FunctionCallError::RespondToModel( + "suggest_reason must not be empty".to_string(), + )); + } + if args.tool_type == DiscoverableToolType::Plugin { + return Err(FunctionCallError::RespondToModel( + "plugin tool suggestions are not currently available".to_string(), + )); + } + if args.action_type != DiscoverableToolAction::Install { + return Err(FunctionCallError::RespondToModel( + "connector tool suggestions currently support only action_type=\"install\"" + .to_string(), + )); + } + + let auth = session.services.auth_manager.auth().await; + let manager = session.services.mcp_connection_manager.read().await; + let mcp_tools = manager.list_all_tools().await; + drop(manager); + let accessible_connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth( + &turn.config, + auth.as_ref(), + &accessible_connectors, + ) + .await + .map(|connectors| { + connectors + .into_iter() + .map(DiscoverableTool::from) + .collect::>() + }) + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "tool suggestions are unavailable right now: {err}" + )) + })?; + + let connector = discoverable_tools + .into_iter() + .find_map(|tool| match tool { + DiscoverableTool::Connector(connector) if connector.id == args.tool_id => { + Some(*connector) + } + DiscoverableTool::Connector(_) | DiscoverableTool::Plugin(_) => None, + }) + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}" + )) + })?; + + let request_id = RequestId::String(format!("tool_suggestion_{call_id}").into()); + let params = build_tool_suggestion_elicitation_request( + session.conversation_id.to_string(), + turn.sub_id.clone(), + &args, + suggest_reason, + &connector, + ); + let response = session + .request_mcp_server_elicitation(turn.as_ref(), request_id, params) + .await; + let user_confirmed = response + .as_ref() + .is_some_and(|response| response.action == ElicitationAction::Accept); + + let completed = if user_confirmed { + let manager = session.services.mcp_connection_manager.read().await; + match manager.hard_refresh_codex_apps_tools_cache().await { + Ok(mcp_tools) => { + let accessible_connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + connectors::refresh_accessible_connectors_cache_from_mcp_tools( + &turn.config, + auth.as_ref(), + &mcp_tools, + ); + verified_connector_suggestion_completed( + args.action_type, + connector.id.as_str(), + &accessible_connectors, + ) + } + Err(err) => { + warn!( + "failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}", + connector.id + ); + false + } + } + } else { + false + }; + + if completed { + session + .merge_connector_selection(HashSet::from([connector.id.clone()])) + .await; + } + + let content = serde_json::to_string(&ToolSuggestResult { + completed, + user_confirmed, + tool_type: args.tool_type, + action_type: args.action_type, + tool_id: connector.id, + tool_name: connector.name, + suggest_reason: suggest_reason.to_string(), + }) + .map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize {TOOL_SUGGEST_TOOL_NAME} response: {err}" + )) + })?; + + Ok(FunctionToolOutput::from_text(content, Some(true))) + } +} + +fn build_tool_suggestion_elicitation_request( + thread_id: String, + turn_id: String, + args: &ToolSuggestArgs, + suggest_reason: &str, + connector: &AppInfo, +) -> McpServerElicitationRequestParams { + let tool_name = connector.name.clone(); + let install_url = connector + .install_url + .clone() + .unwrap_or_else(|| connectors::connector_install_url(&tool_name, &connector.id)); + + let message = format!( + "{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to {} it, then confirm here if you finish.", + args.action_type.as_str() + ); + + McpServerElicitationRequestParams { + thread_id, + turn_id: Some(turn_id), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(json!(build_tool_suggestion_meta( + args.tool_type, + args.action_type, + suggest_reason, + connector.id.as_str(), + tool_name.as_str(), + install_url.as_str(), + ))), + message, + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + } +} + +fn build_tool_suggestion_meta<'a>( + tool_type: DiscoverableToolType, + action_type: DiscoverableToolAction, + suggest_reason: &'a str, + tool_id: &'a str, + tool_name: &'a str, + install_url: &'a str, +) -> ToolSuggestMeta<'a> { + ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type, + suggest_type: action_type, + suggest_reason, + tool_id, + tool_name, + install_url, + } +} + +fn verified_connector_suggestion_completed( + action_type: DiscoverableToolAction, + tool_id: &str, + accessible_connectors: &[AppInfo], +) -> bool { + accessible_connectors + .iter() + .find(|connector| connector.id == tool_id) + .is_some_and(|connector| match action_type { + DiscoverableToolAction::Install => connector.is_accessible, + DiscoverableToolAction::Enable => connector.is_accessible && connector.is_enabled, + }) +} + +#[cfg(test)] +#[path = "tool_suggest_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs new file mode 100644 index 0000000000..a8c4541e91 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs @@ -0,0 +1,167 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn build_tool_suggestion_elicitation_request_uses_expected_shape() { + let args = ToolSuggestArgs { + tool_type: DiscoverableToolType::Connector, + action_type: DiscoverableToolAction::Install, + tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + suggest_reason: "Plan and reference events from your calendar".to_string(), + }; + let connector = AppInfo { + id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some( + "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" + .to_string(), + ), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }; + + let request = build_tool_suggestion_elicitation_request( + "thread-1".to_string(), + "turn-1".to_string(), + &args, + "Plan and reference events from your calendar", + &connector, + ); + + assert_eq!( + request, + McpServerElicitationRequestParams { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(json!(ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type: DiscoverableToolType::Connector, + suggest_type: DiscoverableToolAction::Install, + suggest_reason: "Plan and reference events from your calendar", + tool_id: "connector_2128aebfecb84f64a069897515042a44", + tool_name: "Google Calendar", + install_url: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44", + })), + message: "Google Calendar could help with this request.\n\nPlan and reference events from your calendar\n\nOpen ChatGPT to install it, then confirm here if you finish.".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + } + ); +} + +#[test] +fn build_tool_suggestion_meta_uses_expected_shape() { + let meta = build_tool_suggestion_meta( + DiscoverableToolType::Connector, + DiscoverableToolAction::Install, + "Find and reference emails from your inbox", + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail", + "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2", + ); + + assert_eq!( + meta, + ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type: DiscoverableToolType::Connector, + suggest_type: DiscoverableToolAction::Install, + suggest_reason: "Find and reference emails from your inbox", + tool_id: "connector_68df038e0ba48191908c8434991bbac2", + tool_name: "Gmail", + install_url: "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2", + } + ); +} + +#[test] +fn verified_connector_suggestion_completed_requires_installed_connector() { + let accessible_connectors = vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + + assert!(verified_connector_suggestion_completed( + DiscoverableToolAction::Install, + "calendar", + &accessible_connectors, + )); + assert!(!verified_connector_suggestion_completed( + DiscoverableToolAction::Install, + "gmail", + &accessible_connectors, + )); +} + +#[test] +fn verified_connector_suggestion_completed_requires_enabled_connector_for_enable() { + let accessible_connectors = vec![ + AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: false, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "gmail".to_string(), + name: "Gmail".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ]; + + assert!(!verified_connector_suggestion_completed( + DiscoverableToolAction::Enable, + "calendar", + &accessible_connectors, + )); + assert!(verified_connector_suggestion_completed( + DiscoverableToolAction::Enable, + "gmail", + &accessible_connectors, + )); +} diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index edc6763ef2..02c4987dc3 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -329,131 +329,5 @@ pub(crate) fn get_command( } #[cfg(test)] -mod tests { - use super::*; - use crate::shell::default_user_shell; - use crate::tools::handlers::parse_arguments_with_base_path; - use crate::tools::handlers::resolve_workdir_base_path; - use codex_protocol::models::FileSystemPermissions; - use codex_protocol::models::PermissionProfile; - use codex_utils_absolute_path::AbsolutePathBuf; - use pretty_assertions::assert_eq; - use std::fs; - use std::sync::Arc; - use tempfile::tempdir; - - #[test] - fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()> { - let json = r#"{"cmd": "echo hello"}"#; - - let args: ExecCommandArgs = parse_arguments(json)?; - - assert!(args.shell.is_none()); - - let command = - get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; - - assert_eq!(command.len(), 3); - assert_eq!(command[2], "echo hello"); - Ok(()) - } - - #[test] - fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> { - let json = r#"{"cmd": "echo hello", "shell": "/bin/bash"}"#; - - let args: ExecCommandArgs = parse_arguments(json)?; - - assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - - let command = - get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; - - assert_eq!(command.last(), Some(&"echo hello".to_string())); - if command - .iter() - .any(|arg| arg.eq_ignore_ascii_case("-Command")) - { - assert!(command.contains(&"-NoProfile".to_string())); - } - Ok(()) - } - - #[test] - fn test_get_command_respects_explicit_powershell_shell() -> anyhow::Result<()> { - let json = r#"{"cmd": "echo hello", "shell": "powershell"}"#; - - let args: ExecCommandArgs = parse_arguments(json)?; - - assert_eq!(args.shell.as_deref(), Some("powershell")); - - let command = - get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; - - assert_eq!(command[2], "echo hello"); - Ok(()) - } - - #[test] - fn test_get_command_respects_explicit_cmd_shell() -> anyhow::Result<()> { - let json = r#"{"cmd": "echo hello", "shell": "cmd"}"#; - - let args: ExecCommandArgs = parse_arguments(json)?; - - assert_eq!(args.shell.as_deref(), Some("cmd")); - - let command = - get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; - - assert_eq!(command[2], "echo hello"); - Ok(()) - } - - #[test] - fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<()> { - let json = r#"{"cmd": "echo hello", "login": true}"#; - - let args: ExecCommandArgs = parse_arguments(json)?; - let err = get_command(&args, Arc::new(default_user_shell()), false) - .expect_err("explicit login should be rejected"); - - assert!( - err.contains("login shell is disabled by config"), - "unexpected error: {err}" - ); - Ok(()) - } - - #[test] - fn exec_command_args_resolve_relative_additional_permissions_against_workdir() - -> anyhow::Result<()> { - let cwd = tempdir()?; - let workdir = cwd.path().join("nested"); - fs::create_dir_all(&workdir)?; - let expected_write = workdir.join("relative-write.txt"); - let json = r#"{ - "cmd": "echo hello", - "workdir": "nested", - "additional_permissions": { - "file_system": { - "write": ["./relative-write.txt"] - } - } - }"#; - - let base_path = resolve_workdir_base_path(json, cwd.path())?; - let args: ExecCommandArgs = parse_arguments_with_base_path(json, base_path.as_path())?; - - assert_eq!( - args.additional_permissions, - Some(PermissionProfile { - file_system: Some(FileSystemPermissions { - read: None, - write: Some(vec![AbsolutePathBuf::try_from(expected_write)?]), - }), - ..Default::default() - }) - ); - Ok(()) - } -} +#[path = "unified_exec_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs new file mode 100644 index 0000000000..ee31aefc14 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -0,0 +1,126 @@ +use super::*; +use crate::shell::default_user_shell; +use crate::tools::handlers::parse_arguments_with_base_path; +use crate::tools::handlers::resolve_workdir_base_path; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::fs; +use std::sync::Arc; +use tempfile::tempdir; + +#[test] +fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()> { + let json = r#"{"cmd": "echo hello"}"#; + + let args: ExecCommandArgs = parse_arguments(json)?; + + assert!(args.shell.is_none()); + + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; + + assert_eq!(command.len(), 3); + assert_eq!(command[2], "echo hello"); + Ok(()) +} + +#[test] +fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> { + let json = r#"{"cmd": "echo hello", "shell": "/bin/bash"}"#; + + let args: ExecCommandArgs = parse_arguments(json)?; + + assert_eq!(args.shell.as_deref(), Some("/bin/bash")); + + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; + + assert_eq!(command.last(), Some(&"echo hello".to_string())); + if command + .iter() + .any(|arg| arg.eq_ignore_ascii_case("-Command")) + { + assert!(command.contains(&"-NoProfile".to_string())); + } + Ok(()) +} + +#[test] +fn test_get_command_respects_explicit_powershell_shell() -> anyhow::Result<()> { + let json = r#"{"cmd": "echo hello", "shell": "powershell"}"#; + + let args: ExecCommandArgs = parse_arguments(json)?; + + assert_eq!(args.shell.as_deref(), Some("powershell")); + + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; + + assert_eq!(command[2], "echo hello"); + Ok(()) +} + +#[test] +fn test_get_command_respects_explicit_cmd_shell() -> anyhow::Result<()> { + let json = r#"{"cmd": "echo hello", "shell": "cmd"}"#; + + let args: ExecCommandArgs = parse_arguments(json)?; + + assert_eq!(args.shell.as_deref(), Some("cmd")); + + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; + + assert_eq!(command[2], "echo hello"); + Ok(()) +} + +#[test] +fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<()> { + let json = r#"{"cmd": "echo hello", "login": true}"#; + + let args: ExecCommandArgs = parse_arguments(json)?; + let err = get_command(&args, Arc::new(default_user_shell()), false) + .expect_err("explicit login should be rejected"); + + assert!( + err.contains("login shell is disabled by config"), + "unexpected error: {err}" + ); + Ok(()) +} + +#[test] +fn exec_command_args_resolve_relative_additional_permissions_against_workdir() -> anyhow::Result<()> +{ + let cwd = tempdir()?; + let workdir = cwd.path().join("nested"); + fs::create_dir_all(&workdir)?; + let expected_write = workdir.join("relative-write.txt"); + let json = r#"{ + "cmd": "echo hello", + "workdir": "nested", + "additional_permissions": { + "file_system": { + "write": ["./relative-write.txt"] + } + } + }"#; + + let base_path = resolve_workdir_base_path(json, cwd.path())?; + let args: ExecCommandArgs = parse_arguments_with_base_path(json, base_path.as_path())?; + + assert_eq!( + args.additional_permissions, + Some(PermissionProfile { + file_system: Some(FileSystemPermissions { + read: None, + write: Some(vec![AbsolutePathBuf::try_from(expected_write)?]), + }), + ..Default::default() + }) + ); + Ok(()) +} diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 959e073d78..87e186ee41 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -8,8 +8,8 @@ use codex_utils_image::PromptImageMode; use serde::Deserialize; use tokio::fs; -use crate::features::Feature; use crate::function_tool::FunctionCallError; +use crate::original_image_detail::can_request_original_image_detail; use crate::protocol::EventMsg; use crate::protocol::ViewImageToolCallEvent; use crate::tools::context::FunctionToolOutput; @@ -27,6 +27,12 @@ const VIEW_IMAGE_UNSUPPORTED_MESSAGE: &str = #[derive(Deserialize)] struct ViewImageArgs { path: String, + detail: Option, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum ViewImageDetail { + Original, } #[async_trait] @@ -67,6 +73,19 @@ impl ToolHandler for ViewImageHandler { }; let args: ViewImageArgs = parse_arguments(&arguments)?; + // `view_image` accepts only its documented detail values: omit + // `detail` for the default path or set it to `original`. + // Other string values remain invalid rather than being silently + // reinterpreted. + let detail = match args.detail.as_deref() { + None => None, + Some("original") => Some(ViewImageDetail::Original), + Some(detail) => { + return Err(FunctionCallError::RespondToModel(format!( + "view_image.detail only supports `original`; omit `detail` for default resized behavior, got `{detail}`" + ))); + } + }; let abs_path = turn.resolve_path(Some(args.path)); @@ -85,8 +104,10 @@ impl ToolHandler for ViewImageHandler { } let event_path = abs_path.clone(); - let use_original_detail = turn.config.features.enabled(Feature::ImageDetailOriginal) - && turn.model_info.supports_image_detail_original; + let can_request_original_detail = + can_request_original_image_detail(turn.features.get(), &turn.model_info); + let use_original_detail = + can_request_original_detail && matches!(detail, Some(ViewImageDetail::Original)); let image_mode = if use_original_detail { PromptImageMode::Original } else { diff --git a/codex-rs/core/src/tools/js_repl/kernel.js b/codex-rs/core/src/tools/js_repl/kernel.js index e8f0ac937d..7fd1cbc9c8 100644 --- a/codex-rs/core/src/tools/js_repl/kernel.js +++ b/codex-rs/core/src/tools/js_repl/kernel.js @@ -127,7 +127,9 @@ const pendingTool = new Map(); const pendingEmitImage = new Map(); let toolCounter = 0; let emitImageCounter = 0; -const tmpDir = process.env.CODEX_JS_TMP_DIR || process.cwd(); +const cwd = process.cwd(); +const tmpDir = process.env.CODEX_JS_TMP_DIR || cwd; +const homeDir = process.env.HOME ?? null; const nodeModuleDirEnv = process.env.CODEX_JS_REPL_NODE_MODULE_DIRS ?? ""; const moduleSearchBases = (() => { const bases = []; @@ -150,7 +152,6 @@ const moduleSearchBases = (() => { seen.add(base); bases.push(base); } - const cwd = process.cwd(); if (!seen.has(cwd)) { bases.push(cwd); } @@ -1209,20 +1210,15 @@ function encodeByteImage(bytes, mimeType, detail) { } function parseImageDetail(detail) { - if (typeof detail === "undefined") { + if (detail == null) { return undefined; } if (typeof detail !== "string" || !detail) { throw new Error("codex.emitImage expected detail to be a non-empty string"); } - if ( - detail !== "auto" && - detail !== "low" && - detail !== "high" && - detail !== "original" - ) { + if (detail !== "original") { throw new Error( - 'codex.emitImage expected detail to be one of "auto", "low", "high", or "original"', + 'codex.emitImage only supports detail "original"; omit detail for default behavior', ); } return detail; @@ -1539,12 +1535,12 @@ async function handleExec(message) { priorBindings = builtSource.priorBindings; let output = ""; - context.codex = { tmpDir, tool, emitImage }; + context.codex = { cwd, homeDir, tmpDir, tool, emitImage }; context.tmpDir = tmpDir; await withCapturedConsole(context, async (logs) => { const cellIdentifier = path.join( - process.cwd(), + cwd, `.codex_js_repl_cell_${cellCounter++}.mjs`, ); module = new SourceTextModule(source, { diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 494e0667f5..7a9089a51c 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -36,8 +36,8 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::exec::ExecExpiration; use crate::exec_env::create_env; -use crate::features::Feature; use crate::function_tool::FunctionCallError; +use crate::original_image_detail::normalize_output_image_detail; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; use crate::sandboxing::SandboxPermissions; @@ -637,6 +637,16 @@ impl JsReplManager { summary.result_is_error = Some(!output.success()); summary } + ResponseInputItem::ToolSearchOutput { tools, .. } => JsReplToolCallResponseSummary { + response_type: Some("tool_search_output".to_string()), + payload_kind: Some(JsReplToolCallPayloadKind::FunctionText), + payload_text_preview: Some(serde_json::Value::Array(tools.clone()).to_string()), + payload_text_length: Some( + serde_json::Value::Array(tools.clone()).to_string().len(), + ), + payload_item_count: Some(tools.len()), + ..Default::default() + }, } } @@ -678,8 +688,13 @@ impl JsReplManager { let (stdin, pending_execs, exec_contexts, child, recent_stderr) = { let mut kernel = self.kernel.lock().await; if kernel.is_none() { + let dependency_env = session.dependency_env().await; let state = self - .start_kernel(Arc::clone(&turn), Some(session.conversation_id)) + .start_kernel( + Arc::clone(&turn), + &dependency_env, + Some(session.conversation_id), + ) .await .map_err(FunctionCallError::RespondToModel)?; *kernel = Some(state); @@ -800,6 +815,7 @@ impl JsReplManager { async fn start_kernel( &self, turn: Arc, + dependency_env: &HashMap, thread_id: Option, ) -> Result { let node_path = resolve_compatible_node(self.node_path.as_deref()).await?; @@ -810,6 +826,9 @@ impl JsReplManager { .map_err(|err| err.to_string())?; let mut env = create_env(&turn.shell_environment_policy, thread_id); + if !dependency_env.is_empty() { + env.extend(dependency_env.clone()); + } env.insert( "CODEX_JS_TMP_DIR".to_string(), self.tmp_dir.path().to_string_lossy().to_string(), @@ -865,9 +884,7 @@ impl JsReplManager { #[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: None, codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(), - use_linux_sandbox_bwrap: turn - .features - .enabled(crate::features::Feature::UseLinuxSandboxBwrap), + use_legacy_landlock: turn.features.use_legacy_landlock(), windows_sandbox_level: turn.windows_sandbox_level, }) .map_err(|err| format!("failed to configure sandbox for js_repl: {err}"))?; @@ -1341,36 +1358,43 @@ impl JsReplManager { let router = ToolRouter::from_config( &exec.turn.tools_config, - Some( - mcp_tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect(), - ), - None, - exec.turn.dynamic_tools.as_slice(), + crate::tools::router::ToolRouterParams { + mcp_tools: Some( + mcp_tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect(), + ), + app_tools: None, + discoverable_tools: None, + dynamic_tools: exec.turn.dynamic_tools.as_slice(), + }, ); - let payload = - if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&req.tool_name).await { - crate::tools::context::ToolPayload::Mcp { - server, - tool, - raw_arguments: req.arguments.clone(), - } - } else if is_freeform_tool(&router.specs(), &req.tool_name) { - crate::tools::context::ToolPayload::Custom { - input: req.arguments.clone(), - } - } else { - crate::tools::context::ToolPayload::Function { - arguments: req.arguments.clone(), - } - }; + let payload = if let Some((server, tool)) = exec + .session + .parse_mcp_tool_name(&req.tool_name, &None) + .await + { + crate::tools::context::ToolPayload::Mcp { + server, + tool, + raw_arguments: req.arguments.clone(), + } + } else if is_freeform_tool(&router.specs(), &req.tool_name) { + crate::tools::context::ToolPayload::Custom { + input: req.arguments.clone(), + } + } else { + crate::tools::context::ToolPayload::Function { + arguments: req.arguments.clone(), + } + }; let tool_name = req.tool_name.clone(); let call = crate::tools::router::ToolCall { tool_name: tool_name.clone(), + tool_namespace: None, call_id: req.id.clone(), payload, }; @@ -1469,7 +1493,7 @@ fn emitted_image_content_item( ) -> FunctionCallOutputContentItem { FunctionCallOutputContentItem::InputImage { image_url, - detail: detail.or_else(|| default_output_image_detail_for_turn(turn)), + detail: normalize_output_image_detail(turn.features.get(), &turn.model_info, detail), } } @@ -1484,12 +1508,6 @@ fn validate_emitted_image_url(image_url: &str) -> Result<(), String> { } } -fn default_output_image_detail_for_turn(turn: &TurnContext) -> Option { - (turn.config.features.enabled(Feature::ImageDetailOriginal) - && turn.model_info.supports_image_detail_original) - .then_some(ImageDetail::Original) -} - fn build_exec_result_content_items( output: String, content_items: Vec, @@ -1722,2223 +1740,5 @@ pub(crate) fn resolve_node(config_path: Option<&Path>) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use crate::codex::make_session_and_context; - use crate::codex::make_session_and_context_with_dynamic_tools_and_rx; - use crate::features::Feature; - use crate::protocol::AskForApproval; - use crate::protocol::EventMsg; - use crate::protocol::SandboxPolicy; - use crate::turn_diff_tracker::TurnDiffTracker; - use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; - use codex_protocol::dynamic_tools::DynamicToolResponse; - use codex_protocol::dynamic_tools::DynamicToolSpec; - use codex_protocol::models::FunctionCallOutputContentItem; - use codex_protocol::models::FunctionCallOutputPayload; - use codex_protocol::models::ImageDetail; - use codex_protocol::models::ResponseInputItem; - use codex_protocol::openai_models::InputModality; - use pretty_assertions::assert_eq; - use std::fs; - use std::path::Path; - use tempfile::tempdir; - - fn set_danger_full_access(turn: &mut crate::codex::TurnContext) { - turn.sandbox_policy - .set(SandboxPolicy::DangerFullAccess) - .expect("test setup should allow updating sandbox policy"); - turn.file_system_sandbox_policy = - crate::protocol::FileSystemSandboxPolicy::from(turn.sandbox_policy.get()); - turn.network_sandbox_policy = - crate::protocol::NetworkSandboxPolicy::from(turn.sandbox_policy.get()); - } - - #[test] - fn node_version_parses_v_prefix_and_suffix() { - let version = NodeVersion::parse("v25.1.0-nightly.2024").unwrap(); - assert_eq!( - version, - NodeVersion { - major: 25, - minor: 1, - patch: 0, - } - ); - } - - #[test] - fn truncate_utf8_prefix_by_bytes_preserves_character_boundaries() { - let input = "aé🙂z"; - assert_eq!(truncate_utf8_prefix_by_bytes(input, 0), ""); - assert_eq!(truncate_utf8_prefix_by_bytes(input, 1), "a"); - assert_eq!(truncate_utf8_prefix_by_bytes(input, 2), "a"); - assert_eq!(truncate_utf8_prefix_by_bytes(input, 3), "aé"); - assert_eq!(truncate_utf8_prefix_by_bytes(input, 6), "aé"); - assert_eq!(truncate_utf8_prefix_by_bytes(input, 7), "aé🙂"); - assert_eq!(truncate_utf8_prefix_by_bytes(input, 8), "aé🙂z"); - } - - #[test] - fn stderr_tail_applies_line_and_byte_limits() { - let mut lines = VecDeque::new(); - let per_line_cap = JS_REPL_STDERR_TAIL_LINE_MAX_BYTES.min(JS_REPL_STDERR_TAIL_MAX_BYTES); - let long = "x".repeat(per_line_cap + 128); - let bounded = push_stderr_tail_line(&mut lines, &long); - assert_eq!(bounded.len(), per_line_cap); - - for i in 0..50 { - let line = format!("line-{i}-{}", "y".repeat(200)); - push_stderr_tail_line(&mut lines, &line); - } - - assert!(lines.len() <= JS_REPL_STDERR_TAIL_LINE_LIMIT); - assert!(lines.iter().all(|line| line.len() <= per_line_cap)); - assert!(stderr_tail_formatted_bytes(&lines) <= JS_REPL_STDERR_TAIL_MAX_BYTES); - assert_eq!( - format_stderr_tail(&lines).len(), - stderr_tail_formatted_bytes(&lines) - ); - } - - #[test] - fn model_kernel_failure_details_are_structured_and_truncated() { - let snapshot = KernelDebugSnapshot { - pid: Some(42), - status: "exited(code=1)".to_string(), - stderr_tail: "s".repeat(JS_REPL_MODEL_DIAG_STDERR_MAX_BYTES + 400), - }; - let stream_error = "e".repeat(JS_REPL_MODEL_DIAG_ERROR_MAX_BYTES + 200); - let message = with_model_kernel_failure_message( - "js_repl kernel exited unexpectedly", - "stdout_eof", - Some(&stream_error), - &snapshot, - ); - assert!(message.starts_with("js_repl kernel exited unexpectedly\n\njs_repl diagnostics: ")); - let (_prefix, encoded) = message - .split_once("js_repl diagnostics: ") - .expect("diagnostics suffix should be present"); - let parsed: serde_json::Value = - serde_json::from_str(encoded).expect("diagnostics should be valid json"); - assert_eq!( - parsed.get("reason").and_then(|v| v.as_str()), - Some("stdout_eof") - ); - assert_eq!( - parsed.get("kernel_pid").and_then(serde_json::Value::as_u64), - Some(42) - ); - assert_eq!( - parsed.get("kernel_status").and_then(|v| v.as_str()), - Some("exited(code=1)") - ); - assert!( - parsed - .get("kernel_stderr_tail") - .and_then(|v| v.as_str()) - .expect("kernel_stderr_tail should be present") - .len() - <= JS_REPL_MODEL_DIAG_STDERR_MAX_BYTES - ); - assert!( - parsed - .get("stream_error") - .and_then(|v| v.as_str()) - .expect("stream_error should be present") - .len() - <= JS_REPL_MODEL_DIAG_ERROR_MAX_BYTES - ); - } - - #[test] - fn write_error_diagnostics_only_attach_for_likely_kernel_failures() { - let running = KernelDebugSnapshot { - pid: Some(7), - status: "running".to_string(), - stderr_tail: "".to_string(), - }; - let exited = KernelDebugSnapshot { - pid: Some(7), - status: "exited(code=1)".to_string(), - stderr_tail: "".to_string(), - }; - assert!(!should_include_model_diagnostics_for_write_error( - "failed to flush kernel message: other io error", - &running - )); - assert!(should_include_model_diagnostics_for_write_error( - "failed to write to kernel: Broken pipe (os error 32)", - &running - )); - assert!(should_include_model_diagnostics_for_write_error( - "failed to write to kernel: some other io error", - &exited - )); - } - - #[test] - fn js_repl_internal_tool_guard_matches_expected_names() { - assert!(is_js_repl_internal_tool("js_repl")); - assert!(is_js_repl_internal_tool("js_repl_reset")); - assert!(!is_js_repl_internal_tool("shell_command")); - assert!(!is_js_repl_internal_tool("list_mcp_resources")); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn wait_for_exec_tool_calls_map_drains_inflight_calls_without_hanging() { - let exec_tool_calls = Arc::new(Mutex::new(HashMap::new())); - - for _ in 0..128 { - let exec_id = Uuid::new_v4().to_string(); - exec_tool_calls - .lock() - .await - .insert(exec_id.clone(), ExecToolCalls::default()); - assert!( - JsReplManager::begin_exec_tool_call(&exec_tool_calls, &exec_id) - .await - .is_some() - ); - - let wait_map = Arc::clone(&exec_tool_calls); - let wait_exec_id = exec_id.clone(); - let waiter = tokio::spawn(async move { - JsReplManager::wait_for_exec_tool_calls_map(&wait_map, &wait_exec_id).await; - }); - - let finish_map = Arc::clone(&exec_tool_calls); - let finish_exec_id = exec_id.clone(); - let finisher = tokio::spawn(async move { - tokio::task::yield_now().await; - JsReplManager::finish_exec_tool_call(&finish_map, &finish_exec_id).await; - }); - - tokio::time::timeout(Duration::from_secs(1), waiter) - .await - .expect("wait_for_exec_tool_calls_map should not hang") - .expect("wait task should not panic"); - finisher.await.expect("finish task should not panic"); - - JsReplManager::clear_exec_tool_calls_map(&exec_tool_calls, &exec_id).await; - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn reset_waits_for_exec_lock_before_clearing_exec_tool_calls() { - let manager = JsReplManager::new(None, Vec::new()) - .await - .expect("manager should initialize"); - let permit = manager - .exec_lock - .clone() - .acquire_owned() - .await - .expect("lock should be acquirable"); - let exec_id = Uuid::new_v4().to_string(); - manager.register_exec_tool_calls(&exec_id).await; - - let reset_manager = Arc::clone(&manager); - let mut reset_task = tokio::spawn(async move { reset_manager.reset().await }); - tokio::time::sleep(Duration::from_millis(50)).await; - - assert!( - !reset_task.is_finished(), - "reset should wait until execute lock is released" - ); - assert!( - manager.exec_tool_calls.lock().await.contains_key(&exec_id), - "reset must not clear tool-call contexts while execute lock is held" - ); - - drop(permit); - - tokio::time::timeout(Duration::from_secs(1), &mut reset_task) - .await - .expect("reset should complete after execute lock release") - .expect("reset task should not panic") - .expect("reset should succeed"); - assert!( - !manager.exec_tool_calls.lock().await.contains_key(&exec_id), - "reset should clear tool-call contexts after lock acquisition" - ); - } - - #[test] - fn summarize_tool_call_response_for_multimodal_function_output() { - let response = ResponseInputItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload::from_content_items(vec![ - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,abcd".to_string(), - detail: None, - }, - ]), - }; - - let actual = JsReplManager::summarize_tool_call_response(&response); - - assert_eq!( - actual, - JsReplToolCallResponseSummary { - response_type: Some("function_call_output".to_string()), - payload_kind: Some(JsReplToolCallPayloadKind::FunctionContentItems), - payload_text_preview: None, - payload_text_length: None, - payload_item_count: Some(1), - text_item_count: Some(0), - image_item_count: Some(1), - structured_content_present: None, - result_is_error: None, - } - ); - } - - #[tokio::test] - async fn emitted_image_content_item_preserves_explicit_detail() { - let (_session, turn) = make_session_and_context().await; - let content_item = emitted_image_content_item( - &turn, - "data:image/png;base64,AAA".to_string(), - Some(ImageDetail::Low), - ); - assert_eq!( - content_item, - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(ImageDetail::Low), - } - ); - } - - #[tokio::test] - async fn emitted_image_content_item_uses_turn_original_detail_when_enabled() { - let (_session, mut turn) = make_session_and_context().await; - Arc::make_mut(&mut turn.config) - .features - .enable(Feature::ImageDetailOriginal) - .expect("test config should allow feature update"); - turn.model_info.supports_image_detail_original = true; - - let content_item = - emitted_image_content_item(&turn, "data:image/png;base64,AAA".to_string(), None); - - assert_eq!( - content_item, - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(ImageDetail::Original), - } - ); - } - - #[test] - fn validate_emitted_image_url_accepts_case_insensitive_data_scheme() { - assert_eq!( - validate_emitted_image_url("DATA:image/png;base64,AAA"), - Ok(()) - ); - } - - #[test] - fn validate_emitted_image_url_rejects_non_data_scheme() { - assert_eq!( - validate_emitted_image_url("https://example.com/image.png"), - Err("codex.emitImage only accepts data URLs".to_string()) - ); - } - - #[test] - fn summarize_tool_call_response_for_multimodal_custom_output() { - let response = ResponseInputItem::CustomToolCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload::from_content_items(vec![ - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,abcd".to_string(), - detail: None, - }, - ]), - }; - - let actual = JsReplManager::summarize_tool_call_response(&response); - - assert_eq!( - actual, - JsReplToolCallResponseSummary { - response_type: Some("custom_tool_call_output".to_string()), - payload_kind: Some(JsReplToolCallPayloadKind::CustomContentItems), - payload_text_preview: None, - payload_text_length: None, - payload_item_count: Some(1), - text_item_count: Some(0), - image_item_count: Some(1), - structured_content_present: None, - result_is_error: None, - } - ); - } - - #[test] - fn summarize_tool_call_error_marks_error_payload() { - let actual = JsReplManager::summarize_tool_call_error("tool failed"); - - assert_eq!( - actual, - JsReplToolCallResponseSummary { - response_type: None, - payload_kind: Some(JsReplToolCallPayloadKind::Error), - payload_text_preview: Some("tool failed".to_string()), - payload_text_length: Some("tool failed".len()), - payload_item_count: None, - text_item_count: None, - image_item_count: None, - structured_content_present: None, - result_is_error: None, - } - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn reset_clears_inflight_exec_tool_calls_without_waiting() { - let manager = JsReplManager::new(None, Vec::new()) - .await - .expect("manager should initialize"); - let exec_id = Uuid::new_v4().to_string(); - manager.register_exec_tool_calls(&exec_id).await; - assert!( - JsReplManager::begin_exec_tool_call(&manager.exec_tool_calls, &exec_id) - .await - .is_some() - ); - - let wait_manager = Arc::clone(&manager); - let wait_exec_id = exec_id.clone(); - let waiter = tokio::spawn(async move { - wait_manager.wait_for_exec_tool_calls(&wait_exec_id).await; - }); - tokio::task::yield_now().await; - - tokio::time::timeout(Duration::from_secs(1), manager.reset()) - .await - .expect("reset should not hang") - .expect("reset should succeed"); - - tokio::time::timeout(Duration::from_secs(1), waiter) - .await - .expect("waiter should be released") - .expect("wait task should not panic"); - - assert!(manager.exec_tool_calls.lock().await.is_empty()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn reset_aborts_inflight_exec_tool_tasks() { - let manager = JsReplManager::new(None, Vec::new()) - .await - .expect("manager should initialize"); - let exec_id = Uuid::new_v4().to_string(); - manager.register_exec_tool_calls(&exec_id).await; - let reset_cancel = JsReplManager::begin_exec_tool_call(&manager.exec_tool_calls, &exec_id) - .await - .expect("exec should be registered"); - - let task = tokio::spawn(async move { - tokio::select! { - _ = reset_cancel.cancelled() => "cancelled", - _ = tokio::time::sleep(Duration::from_secs(60)) => "timed_out", - } - }); - - tokio::time::timeout(Duration::from_secs(1), manager.reset()) - .await - .expect("reset should not hang") - .expect("reset should succeed"); - - let outcome = tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("cancelled task should resolve promptly") - .expect("task should not panic"); - assert_eq!(outcome, "cancelled"); - } - - async fn can_run_js_repl_runtime_tests() -> bool { - // These white-box runtime tests are required on macOS. Linux relies on - // the codex-linux-sandbox arg0 dispatch path, which is exercised in - // integration tests instead. - cfg!(target_os = "macos") - } - fn write_js_repl_test_package_source( - base: &Path, - name: &str, - source: &str, - ) -> anyhow::Result<()> { - let pkg_dir = base.join("node_modules").join(name); - fs::create_dir_all(&pkg_dir)?; - fs::write( - pkg_dir.join("package.json"), - format!( - "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"exports\": {{\n \"import\": \"./index.js\"\n }}\n}}\n" - ), - )?; - fs::write(pkg_dir.join("index.js"), source)?; - Ok(()) - } - - fn write_js_repl_test_package(base: &Path, name: &str, value: &str) -> anyhow::Result<()> { - write_js_repl_test_package_source( - base, - name, - &format!("export const value = \"{value}\";\n"), - )?; - Ok(()) - } - - fn write_js_repl_test_module( - base: &Path, - relative: &str, - contents: &str, - ) -> anyhow::Result<()> { - let module_path = base.join(relative); - if let Some(parent) = module_path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(module_path, contents)?; - Ok(()) - } - - #[tokio::test] - async fn js_repl_timeout_does_not_deadlock() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = tokio::time::timeout( - Duration::from_secs(3), - manager.execute( - session, - turn, - tracker, - JsReplArgs { - code: "while (true) {}".to_string(), - timeout_ms: Some(50), - }, - ), - ) - .await - .expect("execute should return, not deadlock") - .expect_err("expected timeout error"); - - assert_eq!( - result.to_string(), - "js_repl execution timed out; kernel reset, rerun your request" - ); - Ok(()) - } - - #[tokio::test] - async fn js_repl_timeout_kills_kernel_process() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "console.log('warmup');".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - - let child = { - let guard = manager.kernel.lock().await; - let state = guard.as_ref().expect("kernel should exist after warmup"); - Arc::clone(&state.child) - }; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "while (true) {}".to_string(), - timeout_ms: Some(50), - }, - ) - .await - .expect_err("expected timeout error"); - - assert_eq!( - result.to_string(), - "js_repl execution timed out; kernel reset, rerun your request" - ); - - let exit_state = { - let mut child = child.lock().await; - child.try_wait()? - }; - assert!( - exit_state.is_some(), - "timed out js_repl execution should kill previous kernel process" - ); - Ok(()) - } - - #[tokio::test] - async fn js_repl_forced_kernel_exit_recovers_on_next_exec() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "console.log('warmup');".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - - let child = { - let guard = manager.kernel.lock().await; - let state = guard.as_ref().expect("kernel should exist after warmup"); - Arc::clone(&state.child) - }; - JsReplManager::kill_kernel_child(&child, "test_crash").await; - tokio::time::timeout(Duration::from_secs(1), async { - loop { - let cleared = { - let guard = manager.kernel.lock().await; - guard - .as_ref() - .is_none_or(|state| !Arc::ptr_eq(&state.child, &child)) - }; - if cleared { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("host should clear dead kernel state promptly"); - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "console.log('after-kill');".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("after-kill")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_uncaught_exception_returns_exec_error_and_recovers() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = crate::codex::make_session_and_context().await; - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "console.log('warmup');".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - - let child = { - let guard = manager.kernel.lock().await; - let state = guard.as_ref().expect("kernel should exist after warmup"); - Arc::clone(&state.child) - }; - - let err = tokio::time::timeout( - Duration::from_secs(3), - manager.execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "setTimeout(() => { throw new Error('boom'); }, 0);\nawait new Promise(() => {});".to_string(), - timeout_ms: Some(10_000), - }, - ), - ) - .await - .expect("uncaught exception should fail promptly") - .expect_err("expected uncaught exception to fail the exec"); - - let message = err.to_string(); - assert!(message.contains("js_repl kernel uncaught exception: boom")); - assert!(message.contains("kernel reset.")); - assert!(message.contains("Catch or handle async errors")); - assert!(!message.contains("js_repl kernel exited unexpectedly")); - - tokio::time::timeout(Duration::from_secs(1), async { - loop { - let exited = { - let mut child = child.lock().await; - child.try_wait()?.is_some() - }; - if exited { - return Ok::<(), anyhow::Error>(()); - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("uncaught exception should terminate the previous kernel process")?; - - tokio::time::timeout(Duration::from_secs(1), async { - loop { - let cleared = { - let guard = manager.kernel.lock().await; - guard - .as_ref() - .is_none_or(|state| !Arc::ptr_eq(&state.child, &child)) - }; - if cleared { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("host should clear dead kernel state promptly"); - - let next = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "console.log('after reset');".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(next.output.contains("after reset")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_waits_for_unawaited_tool_calls_before_completion() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, mut turn) = make_session_and_context().await; - turn.approval_policy - .set(AskForApproval::Never) - .expect("test setup should allow updating approval policy"); - set_danger_full_access(&mut turn); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let marker = turn - .cwd - .join(format!("js-repl-unawaited-marker-{}.txt", Uuid::new_v4())); - let marker_json = serde_json::to_string(&marker.to_string_lossy().to_string())?; - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: format!( - r#" -const marker = {marker_json}; -void codex.tool("shell_command", {{ command: `sleep 0.35; printf js_repl_unawaited_done > "${{marker}}"` }}); -console.log("cell-complete"); -"# - ), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("cell-complete")); - let marker_contents = tokio::fs::read_to_string(&marker).await?; - assert_eq!(marker_contents, "js_repl_unawaited_done"); - let _ = tokio::fs::remove_file(&marker).await; - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_does_not_auto_attach_image_via_view_image_tool() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, mut turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - turn.approval_policy - .set(AskForApproval::Never) - .expect("test setup should allow updating approval policy"); - set_danger_full_access(&mut turn); - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const fs = await import("node:fs/promises"); -const path = await import("node:path"); -const imagePath = path.join(codex.tmpDir, "js-repl-view-image.png"); -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await fs.writeFile(imagePath, png); -const out = await codex.tool("view_image", { path: imagePath }); -console.log(out.type); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert!(result.output.contains("function_call_output")); - assert!(result.content_items.is_empty()); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_can_emit_image_via_view_image_tool() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, mut turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - turn.approval_policy - .set(AskForApproval::Never) - .expect("test setup should allow updating approval policy"); - set_danger_full_access(&mut turn); - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const fs = await import("node:fs/promises"); -const path = await import("node:path"); -const imagePath = path.join(codex.tmpDir, "js-repl-view-image-explicit.png"); -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await fs.writeFile(imagePath, png); -const out = await codex.tool("view_image", { path: imagePath }); -await codex.emitImage(out); -console.log(out.type); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert!(result.output.contains("function_call_output")); - assert_eq!( - result.content_items.as_slice(), - [FunctionCallOutputContentItem::InputImage { - image_url: - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" - .to_string(), - detail: None, - }] - .as_slice() - ); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_can_emit_image_from_bytes_and_mime_type() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await codex.emitImage({ bytes: png, mimeType: "image/png" }); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert_eq!( - result.content_items.as_slice(), - [FunctionCallOutputContentItem::InputImage { - image_url: - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" - .to_string(), - detail: None, - }] - .as_slice() - ); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_can_emit_multiple_images_in_one_cell() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -await codex.emitImage( - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" -); -await codex.emitImage( - "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" -); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert_eq!( - result.content_items.as_slice(), - [ - FunctionCallOutputContentItem::InputImage { - image_url: - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" - .to_string(), - detail: None, - }, - FunctionCallOutputContentItem::InputImage { - image_url: - "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" - .to_string(), - detail: None, - }, - ] - .as_slice() - ); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_waits_for_unawaited_emit_image_before_completion() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -void codex.emitImage( - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" -); -console.log("cell-complete"); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert!(result.output.contains("cell-complete")); - assert_eq!( - result.content_items.as_slice(), - [FunctionCallOutputContentItem::InputImage { - image_url: - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" - .to_string(), - detail: None, - }] - .as_slice() - ); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_unawaited_emit_image_errors_fail_cell() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -void codex.emitImage({ bytes: new Uint8Array(), mimeType: "image/png" }); -console.log("cell-complete"); -"#; - - let err = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await - .expect_err("unawaited invalid emitImage should fail"); - assert!(err.to_string().contains("expected non-empty bytes")); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_caught_emit_image_error_does_not_fail_cell() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -try { - await codex.emitImage({ bytes: new Uint8Array(), mimeType: "image/png" }); -} catch (error) { - console.log(error.message); -} -console.log("cell-complete"); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert!(result.output.contains("expected non-empty bytes")); - assert!(result.output.contains("cell-complete")); - assert!(result.content_items.is_empty()); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_emit_image_requires_explicit_mime_type_for_bytes() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await codex.emitImage({ bytes: png }); -"#; - - let err = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await - .expect_err("missing mimeType should fail"); - assert!(err.to_string().contains("expected a non-empty mimeType")); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_emit_image_rejects_non_data_url() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -await codex.emitImage("https://example.com/image.png"); -"#; - - let err = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await - .expect_err("non-data URLs should fail"); - assert!(err.to_string().contains("only accepts data URLs")); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_emit_image_accepts_case_insensitive_data_url() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -await codex.emitImage("DATA:image/png;base64,AAA"); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert_eq!( - result.content_items.as_slice(), - [FunctionCallOutputContentItem::InputImage { - image_url: "DATA:image/png;base64,AAA".to_string(), - detail: None, - }] - .as_slice() - ); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_emit_image_rejects_invalid_detail() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await codex.emitImage({ bytes: png, mimeType: "image/png", detail: "ultra" }); -"#; - - let err = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await - .expect_err("invalid detail should fail"); - assert!(err.to_string().contains("expected detail to be one of")); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_emit_image_rejects_mixed_content() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn, rx_event) = - make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec { - name: "inline_image".to_string(), - description: "Returns inline text and image content.".to_string(), - input_schema: serde_json::json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }), - }]) - .await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const out = await codex.tool("inline_image", {}); -await codex.emitImage(out); -"#; - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; - - let session_for_response = Arc::clone(&session); - let response_watcher = async move { - loop { - let event = tokio::time::timeout(Duration::from_secs(2), rx_event.recv()).await??; - if let EventMsg::DynamicToolCallRequest(request) = event.msg { - session_for_response - .notify_dynamic_tool_response( - &request.call_id, - DynamicToolResponse { - content_items: vec![ - DynamicToolCallOutputContentItem::InputText { - text: "inline image note".to_string(), - }, - DynamicToolCallOutputContentItem::InputImage { - image_url: image_url.to_string(), - }, - ], - success: true, - }, - ) - .await; - return Ok::<(), anyhow::Error>(()); - } - } - }; - - let (result, response_watcher_result) = tokio::join!( - manager.execute( - Arc::clone(&session), - Arc::clone(&turn), - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ), - response_watcher, - ); - response_watcher_result?; - let err = result.expect_err("mixed content should fail"); - assert!( - err.to_string() - .contains("does not accept mixed text and image content") - ); - assert!(session.get_pending_input_with_metadata().await.is_empty()); - - Ok(()) - } - #[tokio::test] - async fn js_repl_prefers_env_node_module_dirs_over_config() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let env_base = tempdir()?; - write_js_repl_test_package(env_base.path(), "repl_probe", "env")?; - - let config_base = tempdir()?; - let cwd_dir = tempdir()?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy.r#set.insert( - "CODEX_JS_REPL_NODE_MODULE_DIRS".to_string(), - env_base.path().to_string_lossy().to_string(), - ); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - vec![config_base.path().to_path_buf()], - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" - .to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("env")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_resolves_from_first_config_dir() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let first_base = tempdir()?; - let second_base = tempdir()?; - write_js_repl_test_package(first_base.path(), "repl_probe", "first")?; - write_js_repl_test_package(second_base.path(), "repl_probe", "second")?; - - let cwd_dir = tempdir()?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - vec![ - first_base.path().to_path_buf(), - second_base.path().to_path_buf(), - ], - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" - .to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("first")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_falls_back_to_cwd_node_modules() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let config_base = tempdir()?; - let cwd_dir = tempdir()?; - write_js_repl_test_package(cwd_dir.path(), "repl_probe", "cwd")?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - vec![config_base.path().to_path_buf()], - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" - .to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("cwd")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_accepts_node_modules_dir_entries() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let base_dir = tempdir()?; - let cwd_dir = tempdir()?; - write_js_repl_test_package(base_dir.path(), "repl_probe", "normalized")?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - vec![base_dir.path().join("node_modules")], - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" - .to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("normalized")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_supports_relative_file_imports() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - write_js_repl_test_module( - cwd_dir.path(), - "child.js", - "export const value = \"child\";\n", - )?; - write_js_repl_test_module( - cwd_dir.path(), - "parent.js", - "import { value as childValue } from \"./child.js\";\nexport const value = `${childValue}-parent`;\n", - )?; - write_js_repl_test_module( - cwd_dir.path(), - "local.mjs", - "export const value = \"mjs\";\n", - )?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "const parent = await import(\"./parent.js\"); const other = await import(\"./local.mjs\"); console.log(parent.value); console.log(other.value);".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("child-parent")); - assert!(result.output.contains("mjs")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_supports_absolute_file_imports() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let module_dir = tempdir()?; - let cwd_dir = tempdir()?; - write_js_repl_test_module( - module_dir.path(), - "absolute.js", - "export const value = \"absolute\";\n", - )?; - let absolute_path_json = - serde_json::to_string(&module_dir.path().join("absolute.js").display().to_string())?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: format!( - "const mod = await import({absolute_path_json}); console.log(mod.value);" - ), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("absolute")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_imported_local_files_can_access_repl_globals() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - write_js_repl_test_module( - cwd_dir.path(), - "globals.js", - "console.log(codex.tmpDir === tmpDir);\nconsole.log(typeof codex.tool);\nconsole.log(\"local-file-console-ok\");\n", - )?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"./globals.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("true")); - assert!(result.output.contains("function")); - assert!(result.output.contains("local-file-console-ok")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_reimports_local_files_after_edit() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - let helper_path = cwd_dir.path().join("helper.js"); - fs::write(&helper_path, "export const value = \"v1\";\n")?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let first = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "const { value: firstValue } = await import(\"./helper.js\");\nconsole.log(firstValue);".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(first.output.contains("v1")); - - fs::write(&helper_path, "export const value = \"v2\";\n")?; - - let second = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "console.log(firstValue);\nconst { value: secondValue } = await import(\"./helper.js\");\nconsole.log(secondValue);".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(second.output.contains("v1")); - assert!(second.output.contains("v2")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_reimports_local_files_after_fixing_failure() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - let helper_path = cwd_dir.path().join("broken.js"); - fs::write(&helper_path, "throw new Error(\"boom\");\n")?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let err = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "await import(\"./broken.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected broken module import to fail"); - assert!(err.to_string().contains("boom")); - - fs::write(&helper_path, "export const value = \"fixed\";\n")?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "console.log((await import(\"./broken.js\")).value);".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("fixed")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_local_files_expose_node_like_import_meta() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - let pkg_dir = cwd_dir.path().join("node_modules").join("repl_meta_pkg"); - fs::create_dir_all(&pkg_dir)?; - fs::write( - pkg_dir.join("package.json"), - "{\n \"name\": \"repl_meta_pkg\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"exports\": {\n \"import\": \"./index.js\"\n }\n}\n", - )?; - fs::write( - pkg_dir.join("index.js"), - "import { sep } from \"node:path\";\nexport const value = `pkg:${typeof sep}`;\n", - )?; - write_js_repl_test_module( - cwd_dir.path(), - "child.js", - "export const value = \"child-export\";\n", - )?; - write_js_repl_test_module( - cwd_dir.path(), - "meta.js", - "console.log(import.meta.url);\nconsole.log(import.meta.filename);\nconsole.log(import.meta.dirname);\nconsole.log(import.meta.main);\nconsole.log(import.meta.resolve(\"./child.js\"));\nconsole.log(import.meta.resolve(\"repl_meta_pkg\"));\nconsole.log(import.meta.resolve(\"node:fs\"));\nconsole.log((await import(import.meta.resolve(\"./child.js\"))).value);\nconsole.log((await import(import.meta.resolve(\"repl_meta_pkg\"))).value);\n", - )?; - let child_path = fs::canonicalize(cwd_dir.path().join("child.js"))?; - let child_url = url::Url::from_file_path(&child_path) - .expect("child path should convert to file URL") - .to_string(); - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"./meta.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - let cwd_display = cwd_dir.path().display().to_string(); - let meta_path_display = cwd_dir.path().join("meta.js").display().to_string(); - assert!(result.output.contains("file://")); - assert!(result.output.contains(&meta_path_display)); - assert!(result.output.contains(&cwd_display)); - assert!(result.output.contains("false")); - assert!(result.output.contains(&child_url)); - assert!(result.output.contains("repl_meta_pkg")); - assert!(result.output.contains("node:fs")); - assert!(result.output.contains("child-export")); - assert!(result.output.contains("pkg:string")); - Ok(()) - } - - #[tokio::test] - async fn js_repl_rejects_top_level_static_imports_with_clear_error() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let err = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "import \"./local.js\";".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected top-level static import to be rejected"); - assert!( - err.to_string() - .contains("Top-level static import \"./local.js\" is not supported in js_repl") - ); - Ok(()) - } - - #[tokio::test] - async fn js_repl_local_files_reject_static_bare_imports() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - write_js_repl_test_package(cwd_dir.path(), "repl_counter", "pkg")?; - write_js_repl_test_module( - cwd_dir.path(), - "entry.js", - "import { value } from \"repl_counter\";\nconsole.log(value);\n", - )?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let err = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"./entry.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected static bare import to be rejected"); - assert!( - err.to_string().contains( - "Static import \"repl_counter\" is not supported from js_repl local files" - ) - ); - Ok(()) - } - - #[tokio::test] - async fn js_repl_rejects_unsupported_file_specifiers() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - write_js_repl_test_module(cwd_dir.path(), "local.ts", "export const value = \"ts\";\n")?; - write_js_repl_test_module(cwd_dir.path(), "local", "export const value = \"noext\";\n")?; - fs::create_dir_all(cwd_dir.path().join("dir"))?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let unsupported_extension = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "await import(\"./local.ts\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected unsupported extension to be rejected"); - assert!( - unsupported_extension - .to_string() - .contains("Only .js and .mjs files are supported") - ); - - let extensionless = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "await import(\"./local\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected extensionless import to be rejected"); - assert!( - extensionless - .to_string() - .contains("Only .js and .mjs files are supported") - ); - - let directory = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "await import(\"./dir\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected directory import to be rejected"); - assert!( - directory - .to_string() - .contains("Directory imports are not supported") - ); - - let unsupported_url = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"https://example.com/test.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected unsupported url import to be rejected"); - assert!( - unsupported_url - .to_string() - .contains("Unsupported import specifier") - ); - Ok(()) - } - - #[tokio::test] - async fn js_repl_blocks_sensitive_builtin_imports_from_local_files() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - write_js_repl_test_module( - cwd_dir.path(), - "blocked.js", - "import process from \"node:process\";\nconsole.log(process.pid);\n", - )?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let err = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"./blocked.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected blocked builtin import to be rejected"); - assert!( - err.to_string() - .contains("Importing module \"node:process\" is not allowed in js_repl") - ); - Ok(()) - } - - #[tokio::test] - async fn js_repl_local_files_do_not_escape_node_module_search_roots() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let parent_dir = tempdir()?; - write_js_repl_test_package(parent_dir.path(), "repl_probe", "parent")?; - let cwd_dir = parent_dir.path().join("workspace"); - fs::create_dir_all(&cwd_dir)?; - write_js_repl_test_module( - &cwd_dir, - "entry.js", - "const { value } = await import(\"repl_probe\");\nconsole.log(value);\n", - )?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.clone(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let err = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"./entry.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected parent node_modules lookup to be rejected"); - assert!(err.to_string().contains("repl_probe")); - Ok(()) - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/js_repl/mod_tests.rs b/codex-rs/core/src/tools/js_repl/mod_tests.rs new file mode 100644 index 0000000000..cebe4f473a --- /dev/null +++ b/codex-rs/core/src/tools/js_repl/mod_tests.rs @@ -0,0 +1,2208 @@ +use super::*; +use crate::codex::make_session_and_context; +use crate::codex::make_session_and_context_with_dynamic_tools_and_rx; +use crate::features::Feature; +use crate::protocol::AskForApproval; +use crate::protocol::EventMsg; +use crate::protocol::SandboxPolicy; +use crate::turn_diff_tracker::TurnDiffTracker; +use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; +use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ImageDetail; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::openai_models::InputModality; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +fn set_danger_full_access(turn: &mut crate::codex::TurnContext) { + turn.sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); + turn.file_system_sandbox_policy = + crate::protocol::FileSystemSandboxPolicy::from(turn.sandbox_policy.get()); + turn.network_sandbox_policy = + crate::protocol::NetworkSandboxPolicy::from(turn.sandbox_policy.get()); +} + +#[test] +fn node_version_parses_v_prefix_and_suffix() { + let version = NodeVersion::parse("v25.1.0-nightly.2024").unwrap(); + assert_eq!( + version, + NodeVersion { + major: 25, + minor: 1, + patch: 0, + } + ); +} + +#[test] +fn truncate_utf8_prefix_by_bytes_preserves_character_boundaries() { + let input = "aé🙂z"; + assert_eq!(truncate_utf8_prefix_by_bytes(input, 0), ""); + assert_eq!(truncate_utf8_prefix_by_bytes(input, 1), "a"); + assert_eq!(truncate_utf8_prefix_by_bytes(input, 2), "a"); + assert_eq!(truncate_utf8_prefix_by_bytes(input, 3), "aé"); + assert_eq!(truncate_utf8_prefix_by_bytes(input, 6), "aé"); + assert_eq!(truncate_utf8_prefix_by_bytes(input, 7), "aé🙂"); + assert_eq!(truncate_utf8_prefix_by_bytes(input, 8), "aé🙂z"); +} + +#[test] +fn stderr_tail_applies_line_and_byte_limits() { + let mut lines = VecDeque::new(); + let per_line_cap = JS_REPL_STDERR_TAIL_LINE_MAX_BYTES.min(JS_REPL_STDERR_TAIL_MAX_BYTES); + let long = "x".repeat(per_line_cap + 128); + let bounded = push_stderr_tail_line(&mut lines, &long); + assert_eq!(bounded.len(), per_line_cap); + + for i in 0..50 { + let line = format!("line-{i}-{}", "y".repeat(200)); + push_stderr_tail_line(&mut lines, &line); + } + + assert!(lines.len() <= JS_REPL_STDERR_TAIL_LINE_LIMIT); + assert!(lines.iter().all(|line| line.len() <= per_line_cap)); + assert!(stderr_tail_formatted_bytes(&lines) <= JS_REPL_STDERR_TAIL_MAX_BYTES); + assert_eq!( + format_stderr_tail(&lines).len(), + stderr_tail_formatted_bytes(&lines) + ); +} + +#[test] +fn model_kernel_failure_details_are_structured_and_truncated() { + let snapshot = KernelDebugSnapshot { + pid: Some(42), + status: "exited(code=1)".to_string(), + stderr_tail: "s".repeat(JS_REPL_MODEL_DIAG_STDERR_MAX_BYTES + 400), + }; + let stream_error = "e".repeat(JS_REPL_MODEL_DIAG_ERROR_MAX_BYTES + 200); + let message = with_model_kernel_failure_message( + "js_repl kernel exited unexpectedly", + "stdout_eof", + Some(&stream_error), + &snapshot, + ); + assert!(message.starts_with("js_repl kernel exited unexpectedly\n\njs_repl diagnostics: ")); + let (_prefix, encoded) = message + .split_once("js_repl diagnostics: ") + .expect("diagnostics suffix should be present"); + let parsed: serde_json::Value = + serde_json::from_str(encoded).expect("diagnostics should be valid json"); + assert_eq!( + parsed.get("reason").and_then(|v| v.as_str()), + Some("stdout_eof") + ); + assert_eq!( + parsed.get("kernel_pid").and_then(serde_json::Value::as_u64), + Some(42) + ); + assert_eq!( + parsed.get("kernel_status").and_then(|v| v.as_str()), + Some("exited(code=1)") + ); + assert!( + parsed + .get("kernel_stderr_tail") + .and_then(|v| v.as_str()) + .expect("kernel_stderr_tail should be present") + .len() + <= JS_REPL_MODEL_DIAG_STDERR_MAX_BYTES + ); + assert!( + parsed + .get("stream_error") + .and_then(|v| v.as_str()) + .expect("stream_error should be present") + .len() + <= JS_REPL_MODEL_DIAG_ERROR_MAX_BYTES + ); +} + +#[test] +fn write_error_diagnostics_only_attach_for_likely_kernel_failures() { + let running = KernelDebugSnapshot { + pid: Some(7), + status: "running".to_string(), + stderr_tail: "".to_string(), + }; + let exited = KernelDebugSnapshot { + pid: Some(7), + status: "exited(code=1)".to_string(), + stderr_tail: "".to_string(), + }; + assert!(!should_include_model_diagnostics_for_write_error( + "failed to flush kernel message: other io error", + &running + )); + assert!(should_include_model_diagnostics_for_write_error( + "failed to write to kernel: Broken pipe (os error 32)", + &running + )); + assert!(should_include_model_diagnostics_for_write_error( + "failed to write to kernel: some other io error", + &exited + )); +} + +#[test] +fn js_repl_internal_tool_guard_matches_expected_names() { + assert!(is_js_repl_internal_tool("js_repl")); + assert!(is_js_repl_internal_tool("js_repl_reset")); + assert!(!is_js_repl_internal_tool("shell_command")); + assert!(!is_js_repl_internal_tool("list_mcp_resources")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn wait_for_exec_tool_calls_map_drains_inflight_calls_without_hanging() { + let exec_tool_calls = Arc::new(Mutex::new(HashMap::new())); + + for _ in 0..128 { + let exec_id = Uuid::new_v4().to_string(); + exec_tool_calls + .lock() + .await + .insert(exec_id.clone(), ExecToolCalls::default()); + assert!( + JsReplManager::begin_exec_tool_call(&exec_tool_calls, &exec_id) + .await + .is_some() + ); + + let wait_map = Arc::clone(&exec_tool_calls); + let wait_exec_id = exec_id.clone(); + let waiter = tokio::spawn(async move { + JsReplManager::wait_for_exec_tool_calls_map(&wait_map, &wait_exec_id).await; + }); + + let finish_map = Arc::clone(&exec_tool_calls); + let finish_exec_id = exec_id.clone(); + let finisher = tokio::spawn(async move { + tokio::task::yield_now().await; + JsReplManager::finish_exec_tool_call(&finish_map, &finish_exec_id).await; + }); + + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("wait_for_exec_tool_calls_map should not hang") + .expect("wait task should not panic"); + finisher.await.expect("finish task should not panic"); + + JsReplManager::clear_exec_tool_calls_map(&exec_tool_calls, &exec_id).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reset_waits_for_exec_lock_before_clearing_exec_tool_calls() { + let manager = JsReplManager::new(None, Vec::new()) + .await + .expect("manager should initialize"); + let permit = manager + .exec_lock + .clone() + .acquire_owned() + .await + .expect("lock should be acquirable"); + let exec_id = Uuid::new_v4().to_string(); + manager.register_exec_tool_calls(&exec_id).await; + + let reset_manager = Arc::clone(&manager); + let mut reset_task = tokio::spawn(async move { reset_manager.reset().await }); + tokio::time::sleep(Duration::from_millis(50)).await; + + assert!( + !reset_task.is_finished(), + "reset should wait until execute lock is released" + ); + assert!( + manager.exec_tool_calls.lock().await.contains_key(&exec_id), + "reset must not clear tool-call contexts while execute lock is held" + ); + + drop(permit); + + tokio::time::timeout(Duration::from_secs(1), &mut reset_task) + .await + .expect("reset should complete after execute lock release") + .expect("reset task should not panic") + .expect("reset should succeed"); + assert!( + !manager.exec_tool_calls.lock().await.contains_key(&exec_id), + "reset should clear tool-call contexts after lock acquisition" + ); +} + +#[test] +fn summarize_tool_call_response_for_multimodal_function_output() { + let response = ResponseInputItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,abcd".to_string(), + detail: None, + }, + ]), + }; + + let actual = JsReplManager::summarize_tool_call_response(&response); + + assert_eq!( + actual, + JsReplToolCallResponseSummary { + response_type: Some("function_call_output".to_string()), + payload_kind: Some(JsReplToolCallPayloadKind::FunctionContentItems), + payload_text_preview: None, + payload_text_length: None, + payload_item_count: Some(1), + text_item_count: Some(0), + image_item_count: Some(1), + structured_content_present: None, + result_is_error: None, + } + ); +} + +#[tokio::test] +async fn emitted_image_content_item_drops_unsupported_explicit_detail() { + let (_session, turn) = make_session_and_context().await; + let content_item = emitted_image_content_item( + &turn, + "data:image/png;base64,AAA".to_string(), + Some(ImageDetail::Low), + ); + assert_eq!( + content_item, + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: None, + } + ); +} + +#[tokio::test] +async fn emitted_image_content_item_does_not_add_original_detail_when_not_requested() { + let (_session, mut turn) = make_session_and_context().await; + Arc::make_mut(&mut turn.config) + .features + .enable(Feature::ImageDetailOriginal) + .expect("test config should allow feature update"); + turn.model_info.supports_image_detail_original = true; + + let content_item = + emitted_image_content_item(&turn, "data:image/png;base64,AAA".to_string(), None); + + assert_eq!( + content_item, + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: None, + } + ); +} + +#[test] +fn validate_emitted_image_url_accepts_case_insensitive_data_scheme() { + assert_eq!( + validate_emitted_image_url("DATA:image/png;base64,AAA"), + Ok(()) + ); +} + +#[test] +fn validate_emitted_image_url_rejects_non_data_scheme() { + assert_eq!( + validate_emitted_image_url("https://example.com/image.png"), + Err("codex.emitImage only accepts data URLs".to_string()) + ); +} + +#[test] +fn summarize_tool_call_response_for_multimodal_custom_output() { + let response = ResponseInputItem::CustomToolCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,abcd".to_string(), + detail: None, + }, + ]), + }; + + let actual = JsReplManager::summarize_tool_call_response(&response); + + assert_eq!( + actual, + JsReplToolCallResponseSummary { + response_type: Some("custom_tool_call_output".to_string()), + payload_kind: Some(JsReplToolCallPayloadKind::CustomContentItems), + payload_text_preview: None, + payload_text_length: None, + payload_item_count: Some(1), + text_item_count: Some(0), + image_item_count: Some(1), + structured_content_present: None, + result_is_error: None, + } + ); +} + +#[test] +fn summarize_tool_call_error_marks_error_payload() { + let actual = JsReplManager::summarize_tool_call_error("tool failed"); + + assert_eq!( + actual, + JsReplToolCallResponseSummary { + response_type: None, + payload_kind: Some(JsReplToolCallPayloadKind::Error), + payload_text_preview: Some("tool failed".to_string()), + payload_text_length: Some("tool failed".len()), + payload_item_count: None, + text_item_count: None, + image_item_count: None, + structured_content_present: None, + result_is_error: None, + } + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reset_clears_inflight_exec_tool_calls_without_waiting() { + let manager = JsReplManager::new(None, Vec::new()) + .await + .expect("manager should initialize"); + let exec_id = Uuid::new_v4().to_string(); + manager.register_exec_tool_calls(&exec_id).await; + assert!( + JsReplManager::begin_exec_tool_call(&manager.exec_tool_calls, &exec_id) + .await + .is_some() + ); + + let wait_manager = Arc::clone(&manager); + let wait_exec_id = exec_id.clone(); + let waiter = tokio::spawn(async move { + wait_manager.wait_for_exec_tool_calls(&wait_exec_id).await; + }); + tokio::task::yield_now().await; + + tokio::time::timeout(Duration::from_secs(1), manager.reset()) + .await + .expect("reset should not hang") + .expect("reset should succeed"); + + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("waiter should be released") + .expect("wait task should not panic"); + + assert!(manager.exec_tool_calls.lock().await.is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reset_aborts_inflight_exec_tool_tasks() { + let manager = JsReplManager::new(None, Vec::new()) + .await + .expect("manager should initialize"); + let exec_id = Uuid::new_v4().to_string(); + manager.register_exec_tool_calls(&exec_id).await; + let reset_cancel = JsReplManager::begin_exec_tool_call(&manager.exec_tool_calls, &exec_id) + .await + .expect("exec should be registered"); + + let task = tokio::spawn(async move { + tokio::select! { + _ = reset_cancel.cancelled() => "cancelled", + _ = tokio::time::sleep(Duration::from_secs(60)) => "timed_out", + } + }); + + tokio::time::timeout(Duration::from_secs(1), manager.reset()) + .await + .expect("reset should not hang") + .expect("reset should succeed"); + + let outcome = tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("cancelled task should resolve promptly") + .expect("task should not panic"); + assert_eq!(outcome, "cancelled"); +} + +async fn can_run_js_repl_runtime_tests() -> bool { + // These white-box runtime tests are required on macOS. Linux relies on + // the codex-linux-sandbox arg0 dispatch path, which is exercised in + // integration tests instead. + cfg!(target_os = "macos") +} +fn write_js_repl_test_package_source(base: &Path, name: &str, source: &str) -> anyhow::Result<()> { + let pkg_dir = base.join("node_modules").join(name); + fs::create_dir_all(&pkg_dir)?; + fs::write( + pkg_dir.join("package.json"), + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"exports\": {{\n \"import\": \"./index.js\"\n }}\n}}\n" + ), + )?; + fs::write(pkg_dir.join("index.js"), source)?; + Ok(()) +} + +fn write_js_repl_test_package(base: &Path, name: &str, value: &str) -> anyhow::Result<()> { + write_js_repl_test_package_source(base, name, &format!("export const value = \"{value}\";\n"))?; + Ok(()) +} + +fn write_js_repl_test_module(base: &Path, relative: &str, contents: &str) -> anyhow::Result<()> { + let module_path = base.join(relative); + if let Some(parent) = module_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(module_path, contents)?; + Ok(()) +} + +#[tokio::test] +async fn js_repl_timeout_does_not_deadlock() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let result = tokio::time::timeout( + Duration::from_secs(3), + manager.execute( + session, + turn, + tracker, + JsReplArgs { + code: "while (true) {}".to_string(), + timeout_ms: Some(50), + }, + ), + ) + .await + .expect("execute should return, not deadlock") + .expect_err("expected timeout error"); + + assert_eq!( + result.to_string(), + "js_repl execution timed out; kernel reset, rerun your request" + ); + Ok(()) +} + +#[tokio::test] +async fn js_repl_timeout_kills_kernel_process() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + manager + .execute( + Arc::clone(&session), + Arc::clone(&turn), + Arc::clone(&tracker), + JsReplArgs { + code: "console.log('warmup');".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + + let child = { + let guard = manager.kernel.lock().await; + let state = guard.as_ref().expect("kernel should exist after warmup"); + Arc::clone(&state.child) + }; + + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "while (true) {}".to_string(), + timeout_ms: Some(50), + }, + ) + .await + .expect_err("expected timeout error"); + + assert_eq!( + result.to_string(), + "js_repl execution timed out; kernel reset, rerun your request" + ); + + let exit_state = { + let mut child = child.lock().await; + child.try_wait()? + }; + assert!( + exit_state.is_some(), + "timed out js_repl execution should kill previous kernel process" + ); + Ok(()) +} + +#[tokio::test] +async fn js_repl_forced_kernel_exit_recovers_on_next_exec() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + manager + .execute( + Arc::clone(&session), + Arc::clone(&turn), + Arc::clone(&tracker), + JsReplArgs { + code: "console.log('warmup');".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + + let child = { + let guard = manager.kernel.lock().await; + let state = guard.as_ref().expect("kernel should exist after warmup"); + Arc::clone(&state.child) + }; + JsReplManager::kill_kernel_child(&child, "test_crash").await; + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let cleared = { + let guard = manager.kernel.lock().await; + guard + .as_ref() + .is_none_or(|state| !Arc::ptr_eq(&state.child, &child)) + }; + if cleared { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("host should clear dead kernel state promptly"); + + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "console.log('after-kill');".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("after-kill")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_uncaught_exception_returns_exec_error_and_recovers() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = crate::codex::make_session_and_context().await; + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + manager + .execute( + Arc::clone(&session), + Arc::clone(&turn), + Arc::clone(&tracker), + JsReplArgs { + code: "console.log('warmup');".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + + let child = { + let guard = manager.kernel.lock().await; + let state = guard.as_ref().expect("kernel should exist after warmup"); + Arc::clone(&state.child) + }; + + let err = tokio::time::timeout( + Duration::from_secs(3), + manager.execute( + Arc::clone(&session), + Arc::clone(&turn), + Arc::clone(&tracker), + JsReplArgs { + code: "setTimeout(() => { throw new Error('boom'); }, 0);\nawait new Promise(() => {});".to_string(), + timeout_ms: Some(10_000), + }, + ), + ) + .await + .expect("uncaught exception should fail promptly") + .expect_err("expected uncaught exception to fail the exec"); + + let message = err.to_string(); + assert!(message.contains("js_repl kernel uncaught exception: boom")); + assert!(message.contains("kernel reset.")); + assert!(message.contains("Catch or handle async errors")); + assert!(!message.contains("js_repl kernel exited unexpectedly")); + + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let exited = { + let mut child = child.lock().await; + child.try_wait()?.is_some() + }; + if exited { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("uncaught exception should terminate the previous kernel process")?; + + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let cleared = { + let guard = manager.kernel.lock().await; + guard + .as_ref() + .is_none_or(|state| !Arc::ptr_eq(&state.child, &child)) + }; + if cleared { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("host should clear dead kernel state promptly"); + + let next = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "console.log('after reset');".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(next.output.contains("after reset")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_waits_for_unawaited_tool_calls_before_completion() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, mut turn) = make_session_and_context().await; + turn.approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + set_danger_full_access(&mut turn); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let marker = turn + .cwd + .join(format!("js-repl-unawaited-marker-{}.txt", Uuid::new_v4())); + let marker_json = serde_json::to_string(&marker.to_string_lossy().to_string())?; + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: format!( + r#" +const marker = {marker_json}; +void codex.tool("shell_command", {{ command: `sleep 0.35; printf js_repl_unawaited_done > "${{marker}}"` }}); +console.log("cell-complete"); +"# + ), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("cell-complete")); + let marker_contents = tokio::fs::read_to_string(&marker).await?; + assert_eq!(marker_contents, "js_repl_unawaited_done"); + let _ = tokio::fs::remove_file(&marker).await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_does_not_auto_attach_image_via_view_image_tool() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, mut turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + turn.approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + set_danger_full_access(&mut turn); + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +const fs = await import("node:fs/promises"); +const path = await import("node:path"); +const imagePath = path.join(codex.tmpDir, "js-repl-view-image.png"); +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + "base64" +); +await fs.writeFile(imagePath, png); +const out = await codex.tool("view_image", { path: imagePath }); +console.log(out.type); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert!(result.output.contains("function_call_output")); + assert!(result.content_items.is_empty()); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_can_emit_image_via_view_image_tool() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, mut turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + turn.approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + set_danger_full_access(&mut turn); + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +const fs = await import("node:fs/promises"); +const path = await import("node:path"); +const imagePath = path.join(codex.tmpDir, "js-repl-view-image-explicit.png"); +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + "base64" +); +await fs.writeFile(imagePath, png); +const out = await codex.tool("view_image", { path: imagePath }); +await codex.emitImage(out); +console.log(out.type); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert!(result.output.contains("function_call_output")); + assert_eq!( + result.content_items.as_slice(), + [FunctionCallOutputContentItem::InputImage { + image_url: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" + .to_string(), + detail: None, + }] + .as_slice() + ); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_can_emit_image_from_bytes_and_mime_type() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + "base64" +); +await codex.emitImage({ bytes: png, mimeType: "image/png" }); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert_eq!( + result.content_items.as_slice(), + [FunctionCallOutputContentItem::InputImage { + image_url: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" + .to_string(), + detail: None, + }] + .as_slice() + ); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_can_emit_multiple_images_in_one_cell() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +await codex.emitImage( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" +); +await codex.emitImage( + "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" +); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert_eq!( + result.content_items.as_slice(), + [ + FunctionCallOutputContentItem::InputImage { + image_url: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" + .to_string(), + detail: None, + }, + FunctionCallOutputContentItem::InputImage { + image_url: + "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" + .to_string(), + detail: None, + }, + ] + .as_slice() + ); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_waits_for_unawaited_emit_image_before_completion() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +void codex.emitImage( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" +); +console.log("cell-complete"); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert!(result.output.contains("cell-complete")); + assert_eq!( + result.content_items.as_slice(), + [FunctionCallOutputContentItem::InputImage { + image_url: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" + .to_string(), + detail: None, + }] + .as_slice() + ); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_unawaited_emit_image_errors_fail_cell() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +void codex.emitImage({ bytes: new Uint8Array(), mimeType: "image/png" }); +console.log("cell-complete"); +"#; + + let err = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await + .expect_err("unawaited invalid emitImage should fail"); + assert!(err.to_string().contains("expected non-empty bytes")); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_caught_emit_image_error_does_not_fail_cell() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +try { + await codex.emitImage({ bytes: new Uint8Array(), mimeType: "image/png" }); +} catch (error) { + console.log(error.message); +} +console.log("cell-complete"); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert!(result.output.contains("expected non-empty bytes")); + assert!(result.output.contains("cell-complete")); + assert!(result.content_items.is_empty()); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_emit_image_requires_explicit_mime_type_for_bytes() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + "base64" +); +await codex.emitImage({ bytes: png }); +"#; + + let err = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await + .expect_err("missing mimeType should fail"); + assert!(err.to_string().contains("expected a non-empty mimeType")); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_emit_image_rejects_non_data_url() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +await codex.emitImage("https://example.com/image.png"); +"#; + + let err = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await + .expect_err("non-data URLs should fail"); + assert!(err.to_string().contains("only accepts data URLs")); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_emit_image_accepts_case_insensitive_data_url() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +await codex.emitImage("DATA:image/png;base64,AAA"); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert_eq!( + result.content_items.as_slice(), + [FunctionCallOutputContentItem::InputImage { + image_url: "DATA:image/png;base64,AAA".to_string(), + detail: None, + }] + .as_slice() + ); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_emit_image_rejects_invalid_detail() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + "base64" +); +await codex.emitImage({ bytes: png, mimeType: "image/png", detail: "ultra" }); +"#; + + let err = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await + .expect_err("invalid detail should fail"); + assert!( + err.to_string() + .contains("only supports detail \"original\"") + ); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_emit_image_rejects_mixed_content() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn, rx_event) = + make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec { + name: "inline_image".to_string(), + description: "Returns inline text and image content.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }]) + .await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +const out = await codex.tool("inline_image", {}); +await codex.emitImage(out); +"#; + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + + let session_for_response = Arc::clone(&session); + let response_watcher = async move { + loop { + let event = tokio::time::timeout(Duration::from_secs(2), rx_event.recv()).await??; + if let EventMsg::DynamicToolCallRequest(request) = event.msg { + session_for_response + .notify_dynamic_tool_response( + &request.call_id, + DynamicToolResponse { + content_items: vec![ + DynamicToolCallOutputContentItem::InputText { + text: "inline image note".to_string(), + }, + DynamicToolCallOutputContentItem::InputImage { + image_url: image_url.to_string(), + }, + ], + success: true, + }, + ) + .await; + return Ok::<(), anyhow::Error>(()); + } + } + }; + + let (result, response_watcher_result) = tokio::join!( + manager.execute( + Arc::clone(&session), + Arc::clone(&turn), + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ), + response_watcher, + ); + response_watcher_result?; + let err = result.expect_err("mixed content should fail"); + assert!( + err.to_string() + .contains("does not accept mixed text and image content") + ); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} +#[tokio::test] +async fn js_repl_prefers_env_node_module_dirs_over_config() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let env_base = tempdir()?; + write_js_repl_test_package(env_base.path(), "repl_probe", "env")?; + + let config_base = tempdir()?; + let cwd_dir = tempdir()?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy.r#set.insert( + "CODEX_JS_REPL_NODE_MODULE_DIRS".to_string(), + env_base.path().to_string_lossy().to_string(), + ); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + vec![config_base.path().to_path_buf()], + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" + .to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("env")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_resolves_from_first_config_dir() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let first_base = tempdir()?; + let second_base = tempdir()?; + write_js_repl_test_package(first_base.path(), "repl_probe", "first")?; + write_js_repl_test_package(second_base.path(), "repl_probe", "second")?; + + let cwd_dir = tempdir()?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + vec![ + first_base.path().to_path_buf(), + second_base.path().to_path_buf(), + ], + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" + .to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("first")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_falls_back_to_cwd_node_modules() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let config_base = tempdir()?; + let cwd_dir = tempdir()?; + write_js_repl_test_package(cwd_dir.path(), "repl_probe", "cwd")?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + vec![config_base.path().to_path_buf()], + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" + .to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("cwd")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_accepts_node_modules_dir_entries() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let base_dir = tempdir()?; + let cwd_dir = tempdir()?; + write_js_repl_test_package(base_dir.path(), "repl_probe", "normalized")?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + vec![base_dir.path().join("node_modules")], + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" + .to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("normalized")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_supports_relative_file_imports() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let cwd_dir = tempdir()?; + write_js_repl_test_module( + cwd_dir.path(), + "child.js", + "export const value = \"child\";\n", + )?; + write_js_repl_test_module( + cwd_dir.path(), + "parent.js", + "import { value as childValue } from \"./child.js\";\nexport const value = `${childValue}-parent`;\n", + )?; + write_js_repl_test_module( + cwd_dir.path(), + "local.mjs", + "export const value = \"mjs\";\n", + )?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + Vec::new(), + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "const parent = await import(\"./parent.js\"); const other = await import(\"./local.mjs\"); console.log(parent.value); console.log(other.value);".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("child-parent")); + assert!(result.output.contains("mjs")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_supports_absolute_file_imports() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let module_dir = tempdir()?; + let cwd_dir = tempdir()?; + write_js_repl_test_module( + module_dir.path(), + "absolute.js", + "export const value = \"absolute\";\n", + )?; + let absolute_path_json = + serde_json::to_string(&module_dir.path().join("absolute.js").display().to_string())?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + Vec::new(), + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: format!( + "const mod = await import({absolute_path_json}); console.log(mod.value);" + ), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("absolute")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_imported_local_files_can_access_repl_globals() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let cwd_dir = tempdir()?; + write_js_repl_test_module( + cwd_dir.path(), + "globals.js", + "console.log(codex.tmpDir === tmpDir);\nconsole.log(typeof codex.tool);\nconsole.log(\"local-file-console-ok\");\n", + )?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + Vec::new(), + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "await import(\"./globals.js\");".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("true")); + assert!(result.output.contains("function")); + assert!(result.output.contains("local-file-console-ok")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_reimports_local_files_after_edit() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let cwd_dir = tempdir()?; + let helper_path = cwd_dir.path().join("helper.js"); + fs::write(&helper_path, "export const value = \"v1\";\n")?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + Vec::new(), + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let first = manager + .execute( + Arc::clone(&session), + Arc::clone(&turn), + Arc::clone(&tracker), + JsReplArgs { + code: "const { value: firstValue } = await import(\"./helper.js\");\nconsole.log(firstValue);".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(first.output.contains("v1")); + + fs::write(&helper_path, "export const value = \"v2\";\n")?; + + let second = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "console.log(firstValue);\nconst { value: secondValue } = await import(\"./helper.js\");\nconsole.log(secondValue);".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(second.output.contains("v1")); + assert!(second.output.contains("v2")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_reimports_local_files_after_fixing_failure() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let cwd_dir = tempdir()?; + let helper_path = cwd_dir.path().join("broken.js"); + fs::write(&helper_path, "throw new Error(\"boom\");\n")?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + Vec::new(), + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let err = manager + .execute( + Arc::clone(&session), + Arc::clone(&turn), + Arc::clone(&tracker), + JsReplArgs { + code: "await import(\"./broken.js\");".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await + .expect_err("expected broken module import to fail"); + assert!(err.to_string().contains("boom")); + + fs::write(&helper_path, "export const value = \"fixed\";\n")?; + + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "console.log((await import(\"./broken.js\")).value);".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("fixed")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_local_files_expose_node_like_import_meta() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let cwd_dir = tempdir()?; + let pkg_dir = cwd_dir.path().join("node_modules").join("repl_meta_pkg"); + fs::create_dir_all(&pkg_dir)?; + fs::write( + pkg_dir.join("package.json"), + "{\n \"name\": \"repl_meta_pkg\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"exports\": {\n \"import\": \"./index.js\"\n }\n}\n", + )?; + fs::write( + pkg_dir.join("index.js"), + "import { sep } from \"node:path\";\nexport const value = `pkg:${typeof sep}`;\n", + )?; + write_js_repl_test_module( + cwd_dir.path(), + "child.js", + "export const value = \"child-export\";\n", + )?; + write_js_repl_test_module( + cwd_dir.path(), + "meta.js", + "console.log(import.meta.url);\nconsole.log(import.meta.filename);\nconsole.log(import.meta.dirname);\nconsole.log(import.meta.main);\nconsole.log(import.meta.resolve(\"./child.js\"));\nconsole.log(import.meta.resolve(\"repl_meta_pkg\"));\nconsole.log(import.meta.resolve(\"node:fs\"));\nconsole.log((await import(import.meta.resolve(\"./child.js\"))).value);\nconsole.log((await import(import.meta.resolve(\"repl_meta_pkg\"))).value);\n", + )?; + let child_path = fs::canonicalize(cwd_dir.path().join("child.js"))?; + let child_url = url::Url::from_file_path(&child_path) + .expect("child path should convert to file URL") + .to_string(); + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + Vec::new(), + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let result = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "await import(\"./meta.js\");".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + let cwd_display = cwd_dir.path().display().to_string(); + let meta_path_display = cwd_dir.path().join("meta.js").display().to_string(); + assert!(result.output.contains("file://")); + assert!(result.output.contains(&meta_path_display)); + assert!(result.output.contains(&cwd_display)); + assert!(result.output.contains("false")); + assert!(result.output.contains(&child_url)); + assert!(result.output.contains("repl_meta_pkg")); + assert!(result.output.contains("node:fs")); + assert!(result.output.contains("child-export")); + assert!(result.output.contains("pkg:string")); + Ok(()) +} + +#[tokio::test] +async fn js_repl_rejects_top_level_static_imports_with_clear_error() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let err = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "import \"./local.js\";".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await + .expect_err("expected top-level static import to be rejected"); + assert!( + err.to_string() + .contains("Top-level static import \"./local.js\" is not supported in js_repl") + ); + Ok(()) +} + +#[tokio::test] +async fn js_repl_local_files_reject_static_bare_imports() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let cwd_dir = tempdir()?; + write_js_repl_test_package(cwd_dir.path(), "repl_counter", "pkg")?; + write_js_repl_test_module( + cwd_dir.path(), + "entry.js", + "import { value } from \"repl_counter\";\nconsole.log(value);\n", + )?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + Vec::new(), + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let err = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "await import(\"./entry.js\");".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await + .expect_err("expected static bare import to be rejected"); + assert!( + err.to_string() + .contains("Static import \"repl_counter\" is not supported from js_repl local files") + ); + Ok(()) +} + +#[tokio::test] +async fn js_repl_rejects_unsupported_file_specifiers() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let cwd_dir = tempdir()?; + write_js_repl_test_module(cwd_dir.path(), "local.ts", "export const value = \"ts\";\n")?; + write_js_repl_test_module(cwd_dir.path(), "local", "export const value = \"noext\";\n")?; + fs::create_dir_all(cwd_dir.path().join("dir"))?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + Vec::new(), + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let unsupported_extension = manager + .execute( + Arc::clone(&session), + Arc::clone(&turn), + Arc::clone(&tracker), + JsReplArgs { + code: "await import(\"./local.ts\");".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await + .expect_err("expected unsupported extension to be rejected"); + assert!( + unsupported_extension + .to_string() + .contains("Only .js and .mjs files are supported") + ); + + let extensionless = manager + .execute( + Arc::clone(&session), + Arc::clone(&turn), + Arc::clone(&tracker), + JsReplArgs { + code: "await import(\"./local\");".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await + .expect_err("expected extensionless import to be rejected"); + assert!( + extensionless + .to_string() + .contains("Only .js and .mjs files are supported") + ); + + let directory = manager + .execute( + Arc::clone(&session), + Arc::clone(&turn), + Arc::clone(&tracker), + JsReplArgs { + code: "await import(\"./dir\");".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await + .expect_err("expected directory import to be rejected"); + assert!( + directory + .to_string() + .contains("Directory imports are not supported") + ); + + let unsupported_url = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "await import(\"https://example.com/test.js\");".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await + .expect_err("expected unsupported url import to be rejected"); + assert!( + unsupported_url + .to_string() + .contains("Unsupported import specifier") + ); + Ok(()) +} + +#[tokio::test] +async fn js_repl_blocks_sensitive_builtin_imports_from_local_files() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let cwd_dir = tempdir()?; + write_js_repl_test_module( + cwd_dir.path(), + "blocked.js", + "import process from \"node:process\";\nconsole.log(process.pid);\n", + )?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + Vec::new(), + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let err = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "await import(\"./blocked.js\");".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await + .expect_err("expected blocked builtin import to be rejected"); + assert!( + err.to_string() + .contains("Importing module \"node:process\" is not allowed in js_repl") + ); + Ok(()) +} + +#[tokio::test] +async fn js_repl_local_files_do_not_escape_node_module_search_roots() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let parent_dir = tempdir()?; + write_js_repl_test_package(parent_dir.path(), "repl_probe", "parent")?; + let cwd_dir = parent_dir.path().join("workspace"); + fs::create_dir_all(&cwd_dir)?; + write_js_repl_test_module( + &cwd_dir, + "entry.js", + "const { value } = await import(\"repl_probe\");\nconsole.log(value);\n", + )?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.clone(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + Vec::new(), + )); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + + let err = manager + .execute( + session, + turn, + tracker, + JsReplArgs { + code: "await import(\"./entry.js\");".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await + .expect_err("expected parent node_modules lookup to be rejected"); + assert!(err.to_string().contains("repl_probe")); + Ok(()) +} diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 20808325b2..4e495190ec 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -1,6 +1,7 @@ pub mod code_mode; pub(crate) mod code_mode_description; pub mod context; +pub(crate) mod discoverable; pub mod events; pub(crate) mod handlers; pub mod js_repl; diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 78f1bb0f44..756b643701 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -590,206 +590,5 @@ pub(crate) async fn finish_deferred_network_approval( } #[cfg(test)] -mod tests { - use super::*; - use codex_network_proxy::BlockedRequestArgs; - use codex_protocol::protocol::AskForApproval; - use pretty_assertions::assert_eq; - - #[tokio::test] - async fn pending_approvals_are_deduped_per_host_protocol_and_port() { - let service = NetworkApprovalService::default(); - let key = HostApprovalKey { - host: "example.com".to_string(), - protocol: "http", - port: 443, - }; - - let (first, first_is_owner) = service.get_or_create_pending_approval(key.clone()).await; - let (second, second_is_owner) = service.get_or_create_pending_approval(key).await; - - assert!(first_is_owner); - assert!(!second_is_owner); - assert!(Arc::ptr_eq(&first, &second)); - } - - #[tokio::test] - async fn pending_approvals_do_not_dedupe_across_ports() { - let service = NetworkApprovalService::default(); - let first_key = HostApprovalKey { - host: "example.com".to_string(), - protocol: "https", - port: 443, - }; - let second_key = HostApprovalKey { - host: "example.com".to_string(), - protocol: "https", - port: 8443, - }; - - let (first, first_is_owner) = service.get_or_create_pending_approval(first_key).await; - let (second, second_is_owner) = service.get_or_create_pending_approval(second_key).await; - - assert!(first_is_owner); - assert!(second_is_owner); - assert!(!Arc::ptr_eq(&first, &second)); - } - - #[tokio::test] - async fn session_approved_hosts_preserve_protocol_and_port_scope() { - let source = NetworkApprovalService::default(); - { - let mut approved_hosts = source.session_approved_hosts.lock().await; - approved_hosts.extend([ - HostApprovalKey { - host: "example.com".to_string(), - protocol: "https", - port: 443, - }, - HostApprovalKey { - host: "example.com".to_string(), - protocol: "https", - port: 8443, - }, - HostApprovalKey { - host: "example.com".to_string(), - protocol: "http", - port: 80, - }, - ]); - } - - let seeded = NetworkApprovalService::default(); - source.copy_session_approved_hosts_to(&seeded).await; - - let mut copied = seeded - .session_approved_hosts - .lock() - .await - .iter() - .cloned() - .collect::>(); - copied.sort_by(|a, b| (&a.host, a.protocol, a.port).cmp(&(&b.host, b.protocol, b.port))); - - assert_eq!( - copied, - vec![ - HostApprovalKey { - host: "example.com".to_string(), - protocol: "http", - port: 80, - }, - HostApprovalKey { - host: "example.com".to_string(), - protocol: "https", - port: 443, - }, - HostApprovalKey { - host: "example.com".to_string(), - protocol: "https", - port: 8443, - }, - ] - ); - } - - #[tokio::test] - async fn pending_waiters_receive_owner_decision() { - let pending = Arc::new(PendingHostApproval::new()); - - let waiter = { - let pending = Arc::clone(&pending); - tokio::spawn(async move { pending.wait_for_decision().await }) - }; - - pending - .set_decision(PendingApprovalDecision::AllowOnce) - .await; - - let decision = waiter.await.expect("waiter should complete"); - assert_eq!(decision, PendingApprovalDecision::AllowOnce); - } - - #[test] - fn allow_once_and_allow_for_session_both_allow_network() { - assert_eq!( - PendingApprovalDecision::AllowOnce.to_network_decision(), - NetworkDecision::Allow - ); - assert_eq!( - PendingApprovalDecision::AllowForSession.to_network_decision(), - NetworkDecision::Allow - ); - } - - #[test] - fn only_never_policy_disables_network_approval_flow() { - assert!(!allows_network_approval_flow(AskForApproval::Never)); - assert!(allows_network_approval_flow(AskForApproval::OnRequest)); - assert!(allows_network_approval_flow(AskForApproval::OnFailure)); - assert!(allows_network_approval_flow(AskForApproval::UnlessTrusted)); - } - - fn denied_blocked_request(host: &str) -> BlockedRequest { - BlockedRequest::new(BlockedRequestArgs { - host: host.to_string(), - reason: "not_allowed".to_string(), - client: None, - method: None, - mode: None, - protocol: "http".to_string(), - decision: Some("deny".to_string()), - source: Some("decider".to_string()), - port: Some(80), - }) - } - - #[tokio::test] - async fn record_blocked_request_sets_policy_outcome_for_owner_call() { - let service = NetworkApprovalService::default(); - service.register_call("registration-1".to_string()).await; - - service - .record_blocked_request(denied_blocked_request("example.com")) - .await; - - assert_eq!( - service.take_call_outcome("registration-1").await, - Some(NetworkApprovalOutcome::DeniedByPolicy( - "Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string() - )) - ); - } - - #[tokio::test] - async fn blocked_request_policy_does_not_override_user_denial_outcome() { - let service = NetworkApprovalService::default(); - service.register_call("registration-1".to_string()).await; - - service - .record_call_outcome("registration-1", NetworkApprovalOutcome::DeniedByUser) - .await; - service - .record_blocked_request(denied_blocked_request("example.com")) - .await; - - assert_eq!( - service.take_call_outcome("registration-1").await, - Some(NetworkApprovalOutcome::DeniedByUser) - ); - } - - #[tokio::test] - async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests() { - let service = NetworkApprovalService::default(); - service.register_call("registration-1".to_string()).await; - service.register_call("registration-2".to_string()).await; - - service - .record_blocked_request(denied_blocked_request("example.com")) - .await; - - assert_eq!(service.take_call_outcome("registration-1").await, None); - assert_eq!(service.take_call_outcome("registration-2").await, None); - } -} +#[path = "network_approval_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs new file mode 100644 index 0000000000..991d07f042 --- /dev/null +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -0,0 +1,201 @@ +use super::*; +use codex_network_proxy::BlockedRequestArgs; +use codex_protocol::protocol::AskForApproval; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn pending_approvals_are_deduped_per_host_protocol_and_port() { + let service = NetworkApprovalService::default(); + let key = HostApprovalKey { + host: "example.com".to_string(), + protocol: "http", + port: 443, + }; + + let (first, first_is_owner) = service.get_or_create_pending_approval(key.clone()).await; + let (second, second_is_owner) = service.get_or_create_pending_approval(key).await; + + assert!(first_is_owner); + assert!(!second_is_owner); + assert!(Arc::ptr_eq(&first, &second)); +} + +#[tokio::test] +async fn pending_approvals_do_not_dedupe_across_ports() { + let service = NetworkApprovalService::default(); + let first_key = HostApprovalKey { + host: "example.com".to_string(), + protocol: "https", + port: 443, + }; + let second_key = HostApprovalKey { + host: "example.com".to_string(), + protocol: "https", + port: 8443, + }; + + let (first, first_is_owner) = service.get_or_create_pending_approval(first_key).await; + let (second, second_is_owner) = service.get_or_create_pending_approval(second_key).await; + + assert!(first_is_owner); + assert!(second_is_owner); + assert!(!Arc::ptr_eq(&first, &second)); +} + +#[tokio::test] +async fn session_approved_hosts_preserve_protocol_and_port_scope() { + let source = NetworkApprovalService::default(); + { + let mut approved_hosts = source.session_approved_hosts.lock().await; + approved_hosts.extend([ + HostApprovalKey { + host: "example.com".to_string(), + protocol: "https", + port: 443, + }, + HostApprovalKey { + host: "example.com".to_string(), + protocol: "https", + port: 8443, + }, + HostApprovalKey { + host: "example.com".to_string(), + protocol: "http", + port: 80, + }, + ]); + } + + let seeded = NetworkApprovalService::default(); + source.copy_session_approved_hosts_to(&seeded).await; + + let mut copied = seeded + .session_approved_hosts + .lock() + .await + .iter() + .cloned() + .collect::>(); + copied.sort_by(|a, b| (&a.host, a.protocol, a.port).cmp(&(&b.host, b.protocol, b.port))); + + assert_eq!( + copied, + vec![ + HostApprovalKey { + host: "example.com".to_string(), + protocol: "http", + port: 80, + }, + HostApprovalKey { + host: "example.com".to_string(), + protocol: "https", + port: 443, + }, + HostApprovalKey { + host: "example.com".to_string(), + protocol: "https", + port: 8443, + }, + ] + ); +} + +#[tokio::test] +async fn pending_waiters_receive_owner_decision() { + let pending = Arc::new(PendingHostApproval::new()); + + let waiter = { + let pending = Arc::clone(&pending); + tokio::spawn(async move { pending.wait_for_decision().await }) + }; + + pending + .set_decision(PendingApprovalDecision::AllowOnce) + .await; + + let decision = waiter.await.expect("waiter should complete"); + assert_eq!(decision, PendingApprovalDecision::AllowOnce); +} + +#[test] +fn allow_once_and_allow_for_session_both_allow_network() { + assert_eq!( + PendingApprovalDecision::AllowOnce.to_network_decision(), + NetworkDecision::Allow + ); + assert_eq!( + PendingApprovalDecision::AllowForSession.to_network_decision(), + NetworkDecision::Allow + ); +} + +#[test] +fn only_never_policy_disables_network_approval_flow() { + assert!(!allows_network_approval_flow(AskForApproval::Never)); + assert!(allows_network_approval_flow(AskForApproval::OnRequest)); + assert!(allows_network_approval_flow(AskForApproval::OnFailure)); + assert!(allows_network_approval_flow(AskForApproval::UnlessTrusted)); +} + +fn denied_blocked_request(host: &str) -> BlockedRequest { + BlockedRequest::new(BlockedRequestArgs { + host: host.to_string(), + reason: "not_allowed".to_string(), + client: None, + method: None, + mode: None, + protocol: "http".to_string(), + decision: Some("deny".to_string()), + source: Some("decider".to_string()), + port: Some(80), + }) +} + +#[tokio::test] +async fn record_blocked_request_sets_policy_outcome_for_owner_call() { + let service = NetworkApprovalService::default(); + service.register_call("registration-1".to_string()).await; + + service + .record_blocked_request(denied_blocked_request("example.com")) + .await; + + assert_eq!( + service.take_call_outcome("registration-1").await, + Some(NetworkApprovalOutcome::DeniedByPolicy( + "Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string() + )) + ); +} + +#[tokio::test] +async fn blocked_request_policy_does_not_override_user_denial_outcome() { + let service = NetworkApprovalService::default(); + service.register_call("registration-1".to_string()).await; + + service + .record_call_outcome("registration-1", NetworkApprovalOutcome::DeniedByUser) + .await; + service + .record_blocked_request(denied_blocked_request("example.com")) + .await; + + assert_eq!( + service.take_call_outcome("registration-1").await, + Some(NetworkApprovalOutcome::DeniedByUser) + ); +} + +#[tokio::test] +async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests() { + let service = NetworkApprovalService::default(); + service.register_call("registration-1".to_string()).await; + service.register_call("registration-2".to_string()).await; + + service + .record_blocked_request(denied_blocked_request("example.com")) + .await; + + assert_eq!(service.take_call_outcome("registration-1").await, None); + assert_eq!(service.take_call_outcome("registration-2").await, None); +} diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 73c2da3bd7..d773bd913c 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -9,7 +9,6 @@ caching). use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecToolCallOutput; -use crate::features::Feature; use crate::guardian::GUARDIAN_REJECTION_MESSAGE; use crate::guardian::routes_approval_to_guardian; use crate::network_policy_decision::network_approval_context_from_payload; @@ -120,7 +119,7 @@ impl ToolOrchestrator { let mut already_approved = false; let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| { - default_exec_approval_requirement(approval_policy, &turn_ctx.sandbox_policy) + default_exec_approval_requirement(approval_policy, &turn_ctx.file_system_sandbox_policy) }); match requirement { ExecApprovalRequirement::Skip { .. } => { @@ -186,7 +185,7 @@ impl ToolOrchestrator { // Platform-specific flag gating is handled by SandboxManager::select_initial // via crate::safety::get_platform_sandbox(..). - let use_linux_sandbox_bwrap = turn_ctx.features.enabled(Feature::UseLinuxSandboxBwrap); + let use_legacy_landlock = turn_ctx.features.use_legacy_landlock(); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, policy: &turn_ctx.sandbox_policy, @@ -196,7 +195,7 @@ impl ToolOrchestrator { manager: &self.sandbox, sandbox_cwd: &turn_ctx.cwd, codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(), - use_linux_sandbox_bwrap, + use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, }; @@ -249,7 +248,7 @@ impl ToolOrchestrator { && matches!( default_exec_approval_requirement( approval_policy, - &turn_ctx.sandbox_policy + &turn_ctx.file_system_sandbox_policy ), ExecApprovalRequirement::NeedsApproval { .. } ); @@ -318,7 +317,7 @@ impl ToolOrchestrator { manager: &self.sandbox, sandbox_cwd: &turn_ctx.cwd, codex_linux_sandbox_exe: None, - use_linux_sandbox_bwrap, + use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, }; diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 634d1ca716..fad7f5776a 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -122,6 +122,12 @@ impl ToolCallRuntime { ..Default::default() }, }, + ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput { + call_id: call.call_id.clone(), + status: "completed".to_string(), + execution: "client".to_string(), + tools: Vec::new(), + }, ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput { call_id: call.call_id.clone(), output: codex_protocol::mcp::CallToolResult::from_error_text(Self::abort_message( diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index f78df2f1a7..e06e3442a6 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -4,7 +4,6 @@ use std::time::Duration; use std::time::Instant; use crate::client_common::tools::ToolSpec; -use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::memories::usage::emit_metric_for_tool_read; use crate::protocol::SandboxPolicy; @@ -40,6 +39,7 @@ pub trait ToolHandler: Send + Sync { matches!( (self.kind(), payload), (ToolKind::Function, ToolPayload::Function { .. }) + | (ToolKind::Function, ToolPayload::ToolSearch { .. }) | (ToolKind::Mcp, ToolPayload::Mcp { .. }) ) } @@ -121,6 +121,14 @@ where } } +pub(crate) fn tool_handler_key(tool_name: &str, namespace: Option<&str>) -> String { + if let Some(namespace) = namespace { + format!("{namespace}:{tool_name}") + } else { + tool_name.to_string() + } +} + pub struct ToolRegistry { handlers: HashMap>, } @@ -130,8 +138,15 @@ impl ToolRegistry { Self { handlers } } - fn handler(&self, name: &str) -> Option> { - self.handlers.get(name).map(Arc::clone) + fn handler(&self, name: &str, namespace: Option<&str>) -> Option> { + self.handlers + .get(&tool_handler_key(name, namespace)) + .map(Arc::clone) + } + + #[cfg(test)] + pub(crate) fn has_handler(&self, name: &str, namespace: Option<&str>) -> bool { + self.handler(name, namespace).is_some() } // TODO(jif) for dynamic tools. @@ -147,6 +162,7 @@ impl ToolRegistry { invocation: ToolInvocation, ) -> Result { let tool_name = invocation.tool_name.clone(); + let tool_namespace = invocation.tool_namespace.clone(); let call_id_owned = invocation.call_id.clone(); let otel = invocation.turn.session_telemetry.clone(); let payload_for_response = invocation.payload.clone(); @@ -157,10 +173,6 @@ impl ToolRegistry { sandbox_tag( &invocation.turn.sandbox_policy, invocation.turn.windows_sandbox_level, - invocation - .turn - .features - .enabled(Feature::UseLinuxSandboxBwrap), ), ), ( @@ -192,11 +204,14 @@ impl ToolRegistry { } } - let handler = match self.handler(tool_name.as_ref()) { + let handler = match self.handler(tool_name.as_ref(), tool_namespace.as_deref()) { Some(handler) => handler, None => { - let message = - unsupported_tool_call_message(&invocation.payload, tool_name.as_ref()); + let message = unsupported_tool_call_message( + &invocation.payload, + tool_name.as_ref(), + tool_namespace.as_deref(), + ); otel.tool_result_with_tags( tool_name.as_ref(), &call_id_owned, @@ -377,7 +392,12 @@ impl ToolRegistryBuilder { } } -fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &str) -> String { +fn unsupported_tool_call_message( + payload: &ToolPayload, + tool_name: &str, + namespace: Option<&str>, +) -> String { + let tool_name = tool_handler_key(tool_name, namespace); match payload { ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"), _ => format!("unsupported call: {tool_name}"), @@ -401,6 +421,13 @@ impl From<&ToolPayload> for HookToolInput { ToolPayload::Function { arguments } => HookToolInput::Function { arguments: arguments.clone(), }, + ToolPayload::ToolSearch { arguments } => HookToolInput::Function { + arguments: serde_json::json!({ + "query": arguments.query, + "limit": arguments.limit, + }) + .to_string(), + }, ToolPayload::Custom { input } => HookToolInput::Custom { input: input.clone(), }, @@ -470,12 +497,8 @@ async fn dispatch_after_tool_use_hook( success: dispatch.success, duration_ms: u64::try_from(dispatch.duration.as_millis()).unwrap_or(u64::MAX), mutating: dispatch.mutating, - sandbox: sandbox_tag( - &turn.sandbox_policy, - turn.windows_sandbox_level, - turn.features.enabled(Feature::UseLinuxSandboxBwrap), - ) - .to_string(), + sandbox: sandbox_tag(&turn.sandbox_policy, turn.windows_sandbox_level) + .to_string(), sandbox_policy: sandbox_policy_tag(&turn.sandbox_policy).to_string(), output_preview: dispatch.output_preview.clone(), }, @@ -513,3 +536,7 @@ async fn dispatch_after_tool_use_hook( None } + +#[cfg(test)] +#[path = "registry_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs new file mode 100644 index 0000000000..5d9e98df35 --- /dev/null +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -0,0 +1,50 @@ +use super::*; +use crate::tools::context::ToolInvocation; +use async_trait::async_trait; +use pretty_assertions::assert_eq; + +struct TestHandler; + +#[async_trait] +impl ToolHandler for TestHandler { + type Output = crate::tools::context::FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, _invocation: ToolInvocation) -> Result { + unreachable!("test handler should not be invoked") + } +} + +#[test] +fn handler_looks_up_namespaced_aliases_explicitly() { + let plain_handler = Arc::new(TestHandler) as Arc; + let namespaced_handler = Arc::new(TestHandler) as Arc; + let namespace = "mcp__codex_apps__gmail"; + let tool_name = "gmail_get_recent_emails"; + let namespaced_name = tool_handler_key(tool_name, Some(namespace)); + let registry = ToolRegistry::new(HashMap::from([ + (tool_name.to_string(), Arc::clone(&plain_handler)), + (namespaced_name, Arc::clone(&namespaced_handler)), + ])); + + let plain = registry.handler(tool_name, None); + let namespaced = registry.handler(tool_name, Some(namespace)); + let missing_namespaced = registry.handler(tool_name, Some("mcp__codex_apps__calendar")); + + assert_eq!(plain.is_some(), true); + assert_eq!(namespaced.is_some(), true); + assert_eq!(missing_namespaced.is_none(), true); + assert!( + plain + .as_ref() + .is_some_and(|handler| Arc::ptr_eq(handler, &plain_handler)) + ); + assert!( + namespaced + .as_ref() + .is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler)) + ); +} diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 7095a38cea..9d8381c621 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -8,15 +8,18 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::ToolSearchOutput; +use crate::tools::discoverable::DiscoverableTool; use crate::tools::registry::AnyToolResult; use crate::tools::registry::ConfiguredToolSpec; use crate::tools::registry::ToolRegistry; use crate::tools::spec::ToolsConfig; -use crate::tools::spec::build_specs; +use crate::tools::spec::build_specs_with_discoverable_tools; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::LocalShellAction; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_protocol::models::SearchToolCallParams; use codex_protocol::models::ShellToolCallParams; use rmcp::model::Tool; use std::collections::HashMap; @@ -28,6 +31,7 @@ pub use crate::tools::context::ToolCallSource; #[derive(Clone, Debug)] pub struct ToolCall { pub tool_name: String, + pub tool_namespace: Option, pub call_id: String, pub payload: ToolPayload, } @@ -37,14 +41,28 @@ pub struct ToolRouter { specs: Vec, } +pub(crate) struct ToolRouterParams<'a> { + pub(crate) mcp_tools: Option>, + pub(crate) app_tools: Option>, + pub(crate) discoverable_tools: Option>, + pub(crate) dynamic_tools: &'a [DynamicToolSpec], +} + impl ToolRouter { - pub fn from_config( - config: &ToolsConfig, - mcp_tools: Option>, - app_tools: Option>, - dynamic_tools: &[DynamicToolSpec], - ) -> Self { - let builder = build_specs(config, mcp_tools, app_tools, dynamic_tools); + pub fn from_config(config: &ToolsConfig, params: ToolRouterParams<'_>) -> Self { + let ToolRouterParams { + mcp_tools, + app_tools, + discoverable_tools, + dynamic_tools, + } = params; + let builder = build_specs_with_discoverable_tools( + config, + mcp_tools, + app_tools, + discoverable_tools, + dynamic_tools, + ); let (specs, registry) = builder.build(); Self { registry, specs } @@ -72,13 +90,15 @@ impl ToolRouter { match item { ResponseItem::FunctionCall { name, + namespace, arguments, call_id, .. } => { - if let Some((server, tool)) = session.parse_mcp_tool_name(&name).await { + if let Some((server, tool)) = session.parse_mcp_tool_name(&name, &namespace).await { Ok(Some(ToolCall { tool_name: name, + tool_namespace: namespace, call_id, payload: ToolPayload::Mcp { server, @@ -89,11 +109,32 @@ impl ToolRouter { } else { Ok(Some(ToolCall { tool_name: name, + tool_namespace: namespace, call_id, payload: ToolPayload::Function { arguments }, })) } } + ResponseItem::ToolSearchCall { + call_id: Some(call_id), + execution, + arguments, + .. + } if execution == "client" => { + let arguments: SearchToolCallParams = + serde_json::from_value(arguments).map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to parse tool_search arguments: {err}" + )) + })?; + Ok(Some(ToolCall { + tool_name: "tool_search".to_string(), + tool_namespace: None, + call_id, + payload: ToolPayload::ToolSearch { arguments }, + })) + } + ResponseItem::ToolSearchCall { .. } => Ok(None), ResponseItem::CustomToolCall { name, input, @@ -101,6 +142,7 @@ impl ToolRouter { .. } => Ok(Some(ToolCall { tool_name: name, + tool_namespace: None, call_id, payload: ToolPayload::Custom { input }, })), @@ -127,6 +169,7 @@ impl ToolRouter { }; Ok(Some(ToolCall { tool_name: "local_shell".to_string(), + tool_namespace: None, call_id, payload: ToolPayload::LocalShell { params }, })) @@ -163,10 +206,12 @@ impl ToolRouter { ) -> Result { let ToolCall { tool_name, + tool_namespace, call_id, payload, } = call; let payload_outputs_custom = matches!(payload, ToolPayload::Custom { .. }); + let payload_outputs_tool_search = matches!(payload, ToolPayload::ToolSearch { .. }); let failure_call_id = call_id.clone(); if source == ToolCallSource::Direct @@ -180,6 +225,7 @@ impl ToolRouter { return Ok(Self::failure_result( failure_call_id, payload_outputs_custom, + payload_outputs_tool_search, err, )); } @@ -190,6 +236,7 @@ impl ToolRouter { tracker, call_id, tool_name, + tool_namespace, payload, }; @@ -199,6 +246,7 @@ impl ToolRouter { Err(err) => Ok(Self::failure_result( failure_call_id, payload_outputs_custom, + payload_outputs_tool_search, err, )), } @@ -207,10 +255,22 @@ impl ToolRouter { fn failure_result( call_id: String, payload_outputs_custom: bool, + payload_outputs_tool_search: bool, err: FunctionCallError, ) -> AnyToolResult { let message = err.to_string(); - if payload_outputs_custom { + if payload_outputs_tool_search { + AnyToolResult { + call_id, + payload: ToolPayload::ToolSearch { + arguments: SearchToolCallParams { + query: String::new(), + limit: None, + }, + }, + result: Box::new(ToolSearchOutput { tools: Vec::new() }), + } + } else if payload_outputs_custom { AnyToolResult { call_id, payload: ToolPayload::Custom { @@ -230,121 +290,5 @@ impl ToolRouter { } } #[cfg(test)] -mod tests { - use std::sync::Arc; - - use crate::codex::make_session_and_context; - use crate::tools::context::ToolPayload; - use crate::turn_diff_tracker::TurnDiffTracker; - use codex_protocol::models::ResponseInputItem; - - use super::ToolCall; - use super::ToolCallSource; - use super::ToolRouter; - - #[tokio::test] - async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> { - let (session, mut turn) = make_session_and_context().await; - turn.tools_config.js_repl_tools_only = true; - - let session = Arc::new(session); - let turn = Arc::new(turn); - let mcp_tools = session - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await; - let app_tools = Some(mcp_tools.clone()); - let router = ToolRouter::from_config( - &turn.tools_config, - Some( - mcp_tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect(), - ), - app_tools, - turn.dynamic_tools.as_slice(), - ); - - let call = ToolCall { - tool_name: "shell".to_string(), - call_id: "call-1".to_string(), - payload: ToolPayload::Function { - arguments: "{}".to_string(), - }, - }; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let response = router - .dispatch_tool_call(session, turn, tracker, call, ToolCallSource::Direct) - .await?; - - match response { - ResponseInputItem::FunctionCallOutput { output, .. } => { - let content = output.text_content().unwrap_or_default(); - assert!( - content.contains("direct tool calls are disabled"), - "unexpected tool call message: {content}", - ); - } - other => panic!("expected function call output, got {other:?}"), - } - - Ok(()) - } - - #[tokio::test] - async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()> { - let (session, mut turn) = make_session_and_context().await; - turn.tools_config.js_repl_tools_only = true; - - let session = Arc::new(session); - let turn = Arc::new(turn); - let mcp_tools = session - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await; - let app_tools = Some(mcp_tools.clone()); - let router = ToolRouter::from_config( - &turn.tools_config, - Some( - mcp_tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect(), - ), - app_tools, - turn.dynamic_tools.as_slice(), - ); - - let call = ToolCall { - tool_name: "shell".to_string(), - call_id: "call-2".to_string(), - payload: ToolPayload::Function { - arguments: "{}".to_string(), - }, - }; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let response = router - .dispatch_tool_call(session, turn, tracker, call, ToolCallSource::JsRepl) - .await?; - - match response { - ResponseInputItem::FunctionCallOutput { output, .. } => { - let content = output.text_content().unwrap_or_default(); - assert!( - !content.contains("direct tool calls are disabled"), - "js_repl source should bypass direct-call policy gate" - ); - } - other => panic!("expected function call output, got {other:?}"), - } - - Ok(()) - } -} +#[path = "router_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs new file mode 100644 index 0000000000..6350323d1b --- /dev/null +++ b/codex-rs/core/src/tools/router_tests.rs @@ -0,0 +1,161 @@ +use std::sync::Arc; + +use crate::codex::make_session_and_context; +use crate::tools::context::ToolPayload; +use crate::turn_diff_tracker::TurnDiffTracker; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; + +use super::ToolCall; +use super::ToolCallSource; +use super::ToolRouter; +use super::ToolRouterParams; + +#[tokio::test] +async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> { + let (session, mut turn) = make_session_and_context().await; + turn.tools_config.js_repl_tools_only = true; + + let session = Arc::new(session); + let turn = Arc::new(turn); + let mcp_tools = session + .services + .mcp_connection_manager + .read() + .await + .list_all_tools() + .await; + let app_tools = Some(mcp_tools.clone()); + let router = ToolRouter::from_config( + &turn.tools_config, + ToolRouterParams { + mcp_tools: Some( + mcp_tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect(), + ), + app_tools, + discoverable_tools: None, + dynamic_tools: turn.dynamic_tools.as_slice(), + }, + ); + + let call = ToolCall { + tool_name: "shell".to_string(), + tool_namespace: None, + call_id: "call-1".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + }; + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let response = router + .dispatch_tool_call(session, turn, tracker, call, ToolCallSource::Direct) + .await?; + + match response { + ResponseInputItem::FunctionCallOutput { output, .. } => { + let content = output.text_content().unwrap_or_default(); + assert!( + content.contains("direct tool calls are disabled"), + "unexpected tool call message: {content}", + ); + } + other => panic!("expected function call output, got {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()> { + let (session, mut turn) = make_session_and_context().await; + turn.tools_config.js_repl_tools_only = true; + + let session = Arc::new(session); + let turn = Arc::new(turn); + let mcp_tools = session + .services + .mcp_connection_manager + .read() + .await + .list_all_tools() + .await; + let app_tools = Some(mcp_tools.clone()); + let router = ToolRouter::from_config( + &turn.tools_config, + ToolRouterParams { + mcp_tools: Some( + mcp_tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect(), + ), + app_tools, + discoverable_tools: None, + dynamic_tools: turn.dynamic_tools.as_slice(), + }, + ); + + let call = ToolCall { + tool_name: "shell".to_string(), + tool_namespace: None, + call_id: "call-2".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + }; + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let response = router + .dispatch_tool_call(session, turn, tracker, call, ToolCallSource::JsRepl) + .await?; + + match response { + ResponseInputItem::FunctionCallOutput { output, .. } => { + let content = output.text_content().unwrap_or_default(); + assert!( + !content.contains("direct tool calls are disabled"), + "js_repl source should bypass direct-call policy gate" + ); + } + other => panic!("expected function call output, got {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()> { + let (session, _) = make_session_and_context().await; + let session = Arc::new(session); + let tool_name = "create_event".to_string(); + + let call = ToolRouter::build_tool_call( + &session, + ResponseItem::FunctionCall { + id: None, + name: tool_name.clone(), + namespace: Some("mcp__codex_apps__calendar".to_string()), + arguments: "{}".to_string(), + call_id: "call-namespace".to_string(), + }, + ) + .await? + .expect("function_call should produce a tool call"); + + assert_eq!(call.tool_name, tool_name); + assert_eq!( + call.tool_namespace, + Some("mcp__codex_apps__calendar".to_string()) + ); + assert_eq!(call.call_id, "call-namespace"); + match call.payload { + ToolPayload::Function { arguments } => { + assert_eq!(arguments, "{}"); + } + other => panic!("expected function payload, got {other:?}"), + } + + Ok(()) +} diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 18a82bd948..6cf1a4fa05 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -204,74 +204,5 @@ impl ToolRuntime for ApplyPatchRuntime { } #[cfg(test)] -mod tests { - use super::*; - use codex_protocol::protocol::RejectConfig; - use pretty_assertions::assert_eq; - use std::collections::HashMap; - - #[test] - fn wants_no_sandbox_approval_reject_respects_sandbox_flag() { - let runtime = ApplyPatchRuntime::new(); - assert!(runtime.wants_no_sandbox_approval(AskForApproval::OnRequest)); - assert!( - !runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - })) - ); - assert!( - runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig { - sandbox_approval: false, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - })) - ); - } - - #[test] - fn guardian_review_request_includes_full_patch_without_duplicate_changes() { - let path = std::env::temp_dir().join("guardian-apply-patch-test.txt"); - let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); - let expected_cwd = action.cwd.clone(); - let expected_patch = action.patch.clone(); - let request = ApplyPatchRequest { - action, - file_paths: vec![ - AbsolutePathBuf::from_absolute_path(&path).expect("temp path should be absolute"), - ], - changes: HashMap::from([( - path, - FileChange::Add { - content: "hello".to_string(), - }, - )]), - exec_approval_requirement: ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: None, - }, - sandbox_permissions: SandboxPermissions::UseDefault, - additional_permissions: None, - permissions_preapproved: false, - timeout_ms: None, - codex_exe: None, - }; - - let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request); - - assert_eq!( - guardian_request, - GuardianApprovalRequest::ApplyPatch { - cwd: expected_cwd, - files: request.file_paths, - change_count: 1usize, - patch: expected_patch, - } - ); - } -} +#[path = "apply_patch_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs new file mode 100644 index 0000000000..c598162760 --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -0,0 +1,69 @@ +use super::*; +use codex_protocol::protocol::RejectConfig; +use pretty_assertions::assert_eq; +use std::collections::HashMap; + +#[test] +fn wants_no_sandbox_approval_reject_respects_sandbox_flag() { + let runtime = ApplyPatchRuntime::new(); + assert!(runtime.wants_no_sandbox_approval(AskForApproval::OnRequest)); + assert!( + !runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + })) + ); + assert!( + runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + })) + ); +} + +#[test] +fn guardian_review_request_includes_full_patch_without_duplicate_changes() { + let path = std::env::temp_dir().join("guardian-apply-patch-test.txt"); + let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); + let expected_cwd = action.cwd.clone(); + let expected_patch = action.patch.clone(); + let request = ApplyPatchRequest { + action, + file_paths: vec![ + AbsolutePathBuf::from_absolute_path(&path).expect("temp path should be absolute"), + ], + changes: HashMap::from([( + path, + FileChange::Add { + content: "hello".to_string(), + }, + )]), + exec_approval_requirement: ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }, + sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions: None, + permissions_preapproved: false, + timeout_ms: None, + codex_exe: None, + }; + + let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request); + + assert_eq!( + guardian_request, + GuardianApprovalRequest::ApplyPatch { + cwd: expected_cwd, + files: request.file_paths, + change_count: 1usize, + patch: expected_patch, + } + ); +} diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 51002f3ce9..8003819a84 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -177,437 +177,5 @@ fn shell_single_quote(input: &str) -> String { } #[cfg(all(test, unix))] -mod tests { - use super::*; - use crate::shell::ShellType; - use crate::shell_snapshot::ShellSnapshot; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - use std::process::Command; - use std::sync::Arc; - use tempfile::tempdir; - use tokio::sync::watch; - - fn shell_with_snapshot( - shell_type: ShellType, - shell_path: &str, - snapshot_path: PathBuf, - snapshot_cwd: PathBuf, - ) -> Shell { - let (_tx, shell_snapshot) = watch::channel(Some(Arc::new(ShellSnapshot { - path: snapshot_path, - cwd: snapshot_cwd, - }))); - Shell { - shell_type, - shell_path: PathBuf::from(shell_path), - shell_snapshot, - } - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_bootstraps_in_user_shell() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); - let session_shell = shell_with_snapshot( - ShellType::Zsh, - "/bin/zsh", - snapshot_path, - dir.path().to_path_buf(), - ); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "echo hello".to_string(), - ]; - - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - dir.path(), - &HashMap::new(), - ); - - assert_eq!(rewritten[0], "/bin/zsh"); - assert_eq!(rewritten[1], "-c"); - assert!(rewritten[2].contains("if . '")); - assert!(rewritten[2].contains("exec '/bin/bash' -c 'echo hello'")); - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_escapes_single_quotes() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); - let session_shell = shell_with_snapshot( - ShellType::Zsh, - "/bin/zsh", - snapshot_path, - dir.path().to_path_buf(), - ); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "echo 'hello'".to_string(), - ]; - - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - dir.path(), - &HashMap::new(), - ); - - assert!(rewritten[2].contains(r#"exec '/bin/bash' -c 'echo '"'"'hello'"'"''"#)); - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_uses_bash_bootstrap_shell() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); - let session_shell = shell_with_snapshot( - ShellType::Bash, - "/bin/bash", - snapshot_path, - dir.path().to_path_buf(), - ); - let command = vec![ - "/bin/zsh".to_string(), - "-lc".to_string(), - "echo hello".to_string(), - ]; - - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - dir.path(), - &HashMap::new(), - ); - - assert_eq!(rewritten[0], "/bin/bash"); - assert_eq!(rewritten[1], "-c"); - assert!(rewritten[2].contains("if . '")); - assert!(rewritten[2].contains("exec '/bin/zsh' -c 'echo hello'")); - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_uses_sh_bootstrap_shell() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); - let session_shell = shell_with_snapshot( - ShellType::Sh, - "/bin/sh", - snapshot_path, - dir.path().to_path_buf(), - ); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "echo hello".to_string(), - ]; - - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - dir.path(), - &HashMap::new(), - ); - - assert_eq!(rewritten[0], "/bin/sh"); - assert_eq!(rewritten[1], "-c"); - assert!(rewritten[2].contains("if . '")); - assert!(rewritten[2].contains("exec '/bin/bash' -c 'echo hello'")); - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_preserves_trailing_args() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); - let session_shell = shell_with_snapshot( - ShellType::Zsh, - "/bin/zsh", - snapshot_path, - dir.path().to_path_buf(), - ); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "printf '%s %s' \"$0\" \"$1\"".to_string(), - "arg0".to_string(), - "arg1".to_string(), - ]; - - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - dir.path(), - &HashMap::new(), - ); - - assert!( - rewritten[2].contains( - r#"exec '/bin/bash' -c 'printf '"'"'%s %s'"'"' "$0" "$1"' 'arg0' 'arg1'"# - ) - ); - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_skips_when_cwd_mismatch() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); - let snapshot_cwd = dir.path().join("worktree-a"); - let command_cwd = dir.path().join("worktree-b"); - std::fs::create_dir_all(&snapshot_cwd).expect("create snapshot cwd"); - std::fs::create_dir_all(&command_cwd).expect("create command cwd"); - let session_shell = - shell_with_snapshot(ShellType::Zsh, "/bin/zsh", snapshot_path, snapshot_cwd); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "echo hello".to_string(), - ]; - - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - &command_cwd, - &HashMap::new(), - ); - - assert_eq!(rewritten, command); - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_accepts_dot_alias_cwd() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); - let session_shell = shell_with_snapshot( - ShellType::Zsh, - "/bin/zsh", - snapshot_path, - dir.path().to_path_buf(), - ); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "echo hello".to_string(), - ]; - let command_cwd = dir.path().join("."); - - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - &command_cwd, - &HashMap::new(), - ); - - assert_eq!(rewritten[0], "/bin/zsh"); - assert_eq!(rewritten[1], "-c"); - assert!(rewritten[2].contains("if . '")); - assert!(rewritten[2].contains("exec '/bin/bash' -c 'echo hello'")); - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_restores_explicit_override_precedence() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write( - &snapshot_path, - "# Snapshot file\nexport TEST_ENV_SNAPSHOT=global\nexport SNAPSHOT_ONLY=from_snapshot\n", - ) - .expect("write snapshot"); - let session_shell = shell_with_snapshot( - ShellType::Bash, - "/bin/bash", - snapshot_path, - dir.path().to_path_buf(), - ); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "printf '%s|%s' \"$TEST_ENV_SNAPSHOT\" \"${SNAPSHOT_ONLY-unset}\"".to_string(), - ]; - let explicit_env_overrides = - HashMap::from([("TEST_ENV_SNAPSHOT".to_string(), "worktree".to_string())]); - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - dir.path(), - &explicit_env_overrides, - ); - let output = Command::new(&rewritten[0]) - .args(&rewritten[1..]) - .env("TEST_ENV_SNAPSHOT", "worktree") - .output() - .expect("run rewritten command"); - - assert!(output.status.success(), "command failed: {output:?}"); - assert_eq!( - String::from_utf8_lossy(&output.stdout), - "worktree|from_snapshot" - ); - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_keeps_snapshot_path_without_override() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write( - &snapshot_path, - "# Snapshot file\nexport PATH='/snapshot/bin'\n", - ) - .expect("write snapshot"); - let session_shell = shell_with_snapshot( - ShellType::Bash, - "/bin/bash", - snapshot_path, - dir.path().to_path_buf(), - ); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "printf '%s' \"$PATH\"".to_string(), - ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - dir.path(), - &HashMap::new(), - ); - let output = Command::new(&rewritten[0]) - .args(&rewritten[1..]) - .output() - .expect("run rewritten command"); - - assert!(output.status.success(), "command failed: {output:?}"); - assert_eq!(String::from_utf8_lossy(&output.stdout), "/snapshot/bin"); - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write( - &snapshot_path, - "# Snapshot file\nexport PATH='/snapshot/bin'\n", - ) - .expect("write snapshot"); - let session_shell = shell_with_snapshot( - ShellType::Bash, - "/bin/bash", - snapshot_path, - dir.path().to_path_buf(), - ); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "printf '%s' \"$PATH\"".to_string(), - ]; - let explicit_env_overrides = - HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]); - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - dir.path(), - &explicit_env_overrides, - ); - let output = Command::new(&rewritten[0]) - .args(&rewritten[1..]) - .env("PATH", "/worktree/bin") - .output() - .expect("run rewritten command"); - - assert!(output.status.success(), "command failed: {output:?}"); - assert_eq!(String::from_utf8_lossy(&output.stdout), "/worktree/bin"); - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write( - &snapshot_path, - "# Snapshot file\nexport OPENAI_API_KEY='snapshot-value'\n", - ) - .expect("write snapshot"); - let session_shell = shell_with_snapshot( - ShellType::Bash, - "/bin/bash", - snapshot_path, - dir.path().to_path_buf(), - ); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "printf '%s' \"$OPENAI_API_KEY\"".to_string(), - ]; - let explicit_env_overrides = HashMap::from([( - "OPENAI_API_KEY".to_string(), - "super-secret-value".to_string(), - )]); - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - dir.path(), - &explicit_env_overrides, - ); - - assert!(!rewritten[2].contains("super-secret-value")); - let output = Command::new(&rewritten[0]) - .args(&rewritten[1..]) - .env("OPENAI_API_KEY", "super-secret-value") - .output() - .expect("run rewritten command"); - assert!(output.status.success(), "command failed: {output:?}"); - assert_eq!( - String::from_utf8_lossy(&output.stdout), - "super-secret-value" - ); - } - - #[test] - fn maybe_wrap_shell_lc_with_snapshot_preserves_unset_override_variables() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write( - &snapshot_path, - "# Snapshot file\nexport CODEX_TEST_UNSET_OVERRIDE='snapshot-value'\n", - ) - .expect("write snapshot"); - let session_shell = shell_with_snapshot( - ShellType::Bash, - "/bin/bash", - snapshot_path, - dir.path().to_path_buf(), - ); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "if [ \"${CODEX_TEST_UNSET_OVERRIDE+x}\" = x ]; then printf 'set:%s' \"$CODEX_TEST_UNSET_OVERRIDE\"; else printf 'unset'; fi".to_string(), - ]; - let explicit_env_overrides = HashMap::from([( - "CODEX_TEST_UNSET_OVERRIDE".to_string(), - "worktree-value".to_string(), - )]); - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - dir.path(), - &explicit_env_overrides, - ); - - let output = Command::new(&rewritten[0]) - .args(&rewritten[1..]) - .env_remove("CODEX_TEST_UNSET_OVERRIDE") - .output() - .expect("run rewritten command"); - assert!(output.status.success(), "command failed: {output:?}"); - assert_eq!(String::from_utf8_lossy(&output.stdout), "unset"); - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs new file mode 100644 index 0000000000..dbc341d1de --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -0,0 +1,398 @@ +use super::*; +use crate::shell::ShellType; +use crate::shell_snapshot::ShellSnapshot; +use pretty_assertions::assert_eq; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; +use tempfile::tempdir; +use tokio::sync::watch; + +fn shell_with_snapshot( + shell_type: ShellType, + shell_path: &str, + snapshot_path: PathBuf, + snapshot_cwd: PathBuf, +) -> Shell { + let (_tx, shell_snapshot) = watch::channel(Some(Arc::new(ShellSnapshot { + path: snapshot_path, + cwd: snapshot_cwd, + }))); + Shell { + shell_type, + shell_path: PathBuf::from(shell_path), + shell_snapshot, + } +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_bootstraps_in_user_shell() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Zsh, + "/bin/zsh", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "echo hello".to_string(), + ]; + + let rewritten = + maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new()); + + assert_eq!(rewritten[0], "/bin/zsh"); + assert_eq!(rewritten[1], "-c"); + assert!(rewritten[2].contains("if . '")); + assert!(rewritten[2].contains("exec '/bin/bash' -c 'echo hello'")); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_escapes_single_quotes() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Zsh, + "/bin/zsh", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "echo 'hello'".to_string(), + ]; + + let rewritten = + maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new()); + + assert!(rewritten[2].contains(r#"exec '/bin/bash' -c 'echo '"'"'hello'"'"''"#)); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_uses_bash_bootstrap_shell() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/zsh".to_string(), + "-lc".to_string(), + "echo hello".to_string(), + ]; + + let rewritten = + maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new()); + + assert_eq!(rewritten[0], "/bin/bash"); + assert_eq!(rewritten[1], "-c"); + assert!(rewritten[2].contains("if . '")); + assert!(rewritten[2].contains("exec '/bin/zsh' -c 'echo hello'")); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_uses_sh_bootstrap_shell() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Sh, + "/bin/sh", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "echo hello".to_string(), + ]; + + let rewritten = + maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new()); + + assert_eq!(rewritten[0], "/bin/sh"); + assert_eq!(rewritten[1], "-c"); + assert!(rewritten[2].contains("if . '")); + assert!(rewritten[2].contains("exec '/bin/bash' -c 'echo hello'")); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_preserves_trailing_args() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Zsh, + "/bin/zsh", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s %s' \"$0\" \"$1\"".to_string(), + "arg0".to_string(), + "arg1".to_string(), + ]; + + let rewritten = + maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new()); + + assert!( + rewritten[2] + .contains(r#"exec '/bin/bash' -c 'printf '"'"'%s %s'"'"' "$0" "$1"' 'arg0' 'arg1'"#) + ); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_skips_when_cwd_mismatch() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); + let snapshot_cwd = dir.path().join("worktree-a"); + let command_cwd = dir.path().join("worktree-b"); + std::fs::create_dir_all(&snapshot_cwd).expect("create snapshot cwd"); + std::fs::create_dir_all(&command_cwd).expect("create command cwd"); + let session_shell = + shell_with_snapshot(ShellType::Zsh, "/bin/zsh", snapshot_path, snapshot_cwd); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "echo hello".to_string(), + ]; + + let rewritten = + maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, &command_cwd, &HashMap::new()); + + assert_eq!(rewritten, command); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_accepts_dot_alias_cwd() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Zsh, + "/bin/zsh", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "echo hello".to_string(), + ]; + let command_cwd = dir.path().join("."); + + let rewritten = + maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, &command_cwd, &HashMap::new()); + + assert_eq!(rewritten[0], "/bin/zsh"); + assert_eq!(rewritten[1], "-c"); + assert!(rewritten[2].contains("if . '")); + assert!(rewritten[2].contains("exec '/bin/bash' -c 'echo hello'")); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_restores_explicit_override_precedence() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport TEST_ENV_SNAPSHOT=global\nexport SNAPSHOT_ONLY=from_snapshot\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s|%s' \"$TEST_ENV_SNAPSHOT\" \"${SNAPSHOT_ONLY-unset}\"".to_string(), + ]; + let explicit_env_overrides = + HashMap::from([("TEST_ENV_SNAPSHOT".to_string(), "worktree".to_string())]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("TEST_ENV_SNAPSHOT", "worktree") + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "worktree|from_snapshot" + ); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_keeps_snapshot_path_without_override() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport PATH='/snapshot/bin'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$PATH\"".to_string(), + ]; + let rewritten = + maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new()); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "/snapshot/bin"); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport PATH='/snapshot/bin'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$PATH\"".to_string(), + ]; + let explicit_env_overrides = HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("PATH", "/worktree/bin") + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "/worktree/bin"); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport OPENAI_API_KEY='snapshot-value'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$OPENAI_API_KEY\"".to_string(), + ]; + let explicit_env_overrides = HashMap::from([( + "OPENAI_API_KEY".to_string(), + "super-secret-value".to_string(), + )]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + + assert!(!rewritten[2].contains("super-secret-value")); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("OPENAI_API_KEY", "super-secret-value") + .output() + .expect("run rewritten command"); + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "super-secret-value" + ); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_preserves_unset_override_variables() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport CODEX_TEST_UNSET_OVERRIDE='snapshot-value'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "if [ \"${CODEX_TEST_UNSET_OVERRIDE+x}\" = x ]; then printf 'set:%s' \"$CODEX_TEST_UNSET_OVERRIDE\"; else printf 'unset'; fi".to_string(), + ]; + let explicit_env_overrides = HashMap::from([( + "CODEX_TEST_UNSET_OVERRIDE".to_string(), + "worktree-value".to_string(), + )]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env_remove("CODEX_TEST_UNSET_OVERRIDE") + .output() + .expect("run rewritten command"); + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "unset"); +} diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index dea0aa202a..daf8e82906 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -51,6 +51,8 @@ pub struct ShellRequest { pub network: Option, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, + #[cfg(unix)] + pub additional_permissions_preapproved: bool, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 35a4e332e9..4f3240c129 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -71,6 +71,22 @@ const REJECT_RULES_APPROVAL_REASON: &str = const REJECT_SKILL_APPROVAL_REASON: &str = "approval required by skill, but AskForApproval::Reject.skill_approval is set"; +fn approval_sandbox_permissions( + sandbox_permissions: SandboxPermissions, + additional_permissions_preapproved: bool, +) -> SandboxPermissions { + if additional_permissions_preapproved + && matches!( + sandbox_permissions, + SandboxPermissions::WithAdditionalPermissions + ) + { + SandboxPermissions::UseDefault + } else { + sandbox_permissions + } +} + pub(super) async fn try_run_zsh_fork( req: &ShellRequest, attempt: &SandboxAttempt<'_>, @@ -146,7 +162,7 @@ pub(super) async fn try_run_zsh_fork( .macos_seatbelt_profile_extensions .clone(), codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), - use_linux_sandbox_bwrap: ctx.turn.features.enabled(Feature::UseLinuxSandboxBwrap), + use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), }; let main_execve_wrapper_exe = ctx .session @@ -170,6 +186,10 @@ pub(super) async fn try_run_zsh_fork( // escalation server. let stopwatch = Stopwatch::new(effective_timeout); let cancel_token = stopwatch.cancellation_token(); + let approval_sandbox_permissions = approval_sandbox_permissions( + req.sandbox_permissions, + req.additional_permissions_preapproved, + ); let escalation_policy = CoreShellActionProvider { policy: Arc::clone(&exec_policy), session: Arc::clone(&ctx.session), @@ -181,6 +201,7 @@ pub(super) async fn try_run_zsh_fork( file_system_sandbox_policy: command_executor.file_system_sandbox_policy.clone(), network_sandbox_policy: command_executor.network_sandbox_policy, sandbox_permissions: req.sandbox_permissions, + approval_sandbox_permissions, prompt_permissions: req.additional_permissions.clone(), stopwatch: stopwatch.clone(), }; @@ -258,7 +279,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( .macos_seatbelt_profile_extensions .clone(), codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), - use_linux_sandbox_bwrap: ctx.turn.features.enabled(Feature::UseLinuxSandboxBwrap), + use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), }; let main_execve_wrapper_exe = ctx .session @@ -281,6 +302,10 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(), network_sandbox_policy: exec_request.network_sandbox_policy, sandbox_permissions: req.sandbox_permissions, + approval_sandbox_permissions: approval_sandbox_permissions( + req.sandbox_permissions, + req.additional_permissions_preapproved, + ), prompt_permissions: req.additional_permissions.clone(), stopwatch: Stopwatch::unlimited(), }; @@ -312,6 +337,7 @@ struct CoreShellActionProvider { file_system_sandbox_policy: FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, sandbox_permissions: SandboxPermissions, + approval_sandbox_permissions: SandboxPermissions, prompt_permissions: Option, stopwatch: Stopwatch, } @@ -692,10 +718,14 @@ impl EscalationPolicy for CoreShellActionProvider { &policy, program, argv, - self.approval_policy, - &self.sandbox_policy, - self.sandbox_permissions, - ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING, + InterceptedExecPolicyContext { + approval_policy: self.approval_policy, + sandbox_policy: &self.sandbox_policy, + file_system_sandbox_policy: &self.file_system_sandbox_policy, + sandbox_permissions: self.approval_sandbox_permissions, + enable_shell_wrapper_parsing: + ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING, + }, ) }; // When true, means the Evaluation was due to *.rules, not the @@ -744,15 +774,19 @@ fn evaluate_intercepted_exec_policy( policy: &Policy, program: &AbsolutePathBuf, argv: &[String], - approval_policy: AskForApproval, - sandbox_policy: &SandboxPolicy, - sandbox_permissions: SandboxPermissions, - enable_intercepted_exec_policy_shell_wrapper_parsing: bool, + context: InterceptedExecPolicyContext<'_>, ) -> Evaluation { + let InterceptedExecPolicyContext { + approval_policy, + sandbox_policy, + file_system_sandbox_policy, + sandbox_permissions, + enable_shell_wrapper_parsing, + } = context; let CandidateCommands { commands, used_complex_parsing, - } = if enable_intercepted_exec_policy_shell_wrapper_parsing { + } = if enable_shell_wrapper_parsing { // In this codepath, the first argument in `commands` could be a bare // name like `find` instead of an absolute path like `/usr/bin/find`. // It could also be a shell built-in like `echo`. @@ -770,6 +804,7 @@ fn evaluate_intercepted_exec_policy( crate::exec_policy::render_decision_for_unmatched_command( approval_policy, sandbox_policy, + file_system_sandbox_policy, cmd, sandbox_permissions, used_complex_parsing, @@ -785,6 +820,15 @@ fn evaluate_intercepted_exec_policy( ) } +#[derive(Clone, Copy)] +struct InterceptedExecPolicyContext<'a> { + approval_policy: AskForApproval, + sandbox_policy: &'a SandboxPolicy, + file_system_sandbox_policy: &'a FileSystemSandboxPolicy, + sandbox_permissions: SandboxPermissions, + enable_shell_wrapper_parsing: bool, +} + struct CandidateCommands { commands: Vec>, used_complex_parsing: bool, @@ -837,7 +881,7 @@ struct CoreShellCommandExecutor { #[cfg_attr(not(target_os = "macos"), allow(dead_code))] macos_seatbelt_profile_extensions: Option, codex_linux_sandbox_exe: Option, - use_linux_sandbox_bwrap: bool, + use_legacy_landlock: bool, } struct PrepareSandboxedExecParams<'a> { @@ -1034,7 +1078,7 @@ impl CoreShellCommandExecutor { #[cfg(target_os = "macos")] macos_seatbelt_profile_extensions, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(), - use_linux_sandbox_bwrap: self.use_linux_sandbox_bwrap, + use_legacy_landlock: self.use_legacy_landlock, windows_sandbox_level: self.windows_sandbox_level, })?; if let Some(network) = exec_request.network.as_ref() { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 02779650f4..23f813670f 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -1,6 +1,7 @@ use super::CoreShellActionProvider; #[cfg(target_os = "macos")] use super::CoreShellCommandExecutor; +use super::InterceptedExecPolicyContext; use super::ParsedShellCommand; use super::commands_for_intercepted_exec_policy; use super::evaluate_intercepted_exec_policy; @@ -36,6 +37,7 @@ use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_shell_escalation::EscalationExecution; @@ -67,6 +69,20 @@ fn starlark_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } +fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }]) +} + +#[cfg(target_os = "macos")] +fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { + FileSystemSandboxPolicy::unrestricted() +} + fn test_skill_metadata(permission_profile: Option) -> SkillMetadata { SkillMetadata { name: "skill".to_string(), @@ -149,6 +165,22 @@ fn execve_prompt_rejection_keeps_unmatched_commands_on_sandbox_flag() { ); } +#[test] +fn approval_sandbox_permissions_only_downgrades_preapproved_additional_permissions() { + assert_eq!( + super::approval_sandbox_permissions(SandboxPermissions::WithAdditionalPermissions, true), + SandboxPermissions::UseDefault, + ); + assert_eq!( + super::approval_sandbox_permissions(SandboxPermissions::WithAdditionalPermissions, false), + SandboxPermissions::WithAdditionalPermissions, + ); + assert_eq!( + super::approval_sandbox_permissions(SandboxPermissions::RequireEscalated, true), + SandboxPermissions::RequireEscalated, + ); +} + #[test] fn extract_shell_script_preserves_login_flag() { assert_eq!( @@ -412,10 +444,13 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars "-lc".to_string(), "npm publish".to_string(), ], - AskForApproval::OnRequest, - &SandboxPolicy::new_read_only_policy(), - SandboxPermissions::UseDefault, - enable_intercepted_exec_policy_shell_wrapper_parsing, + InterceptedExecPolicyContext { + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, + }, ); assert!( @@ -460,10 +495,13 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() "-lc".to_string(), "npm publish".to_string(), ], - AskForApproval::OnRequest, - &SandboxPolicy::new_read_only_policy(), - SandboxPermissions::UseDefault, - enable_intercepted_exec_policy_shell_wrapper_parsing, + InterceptedExecPolicyContext { + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, + }, ); assert_eq!( @@ -499,10 +537,13 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) &policy, &program, &["git".to_string(), "status".to_string()], - AskForApproval::OnRequest, - &SandboxPolicy::new_read_only_policy(), - SandboxPermissions::UseDefault, - false, + InterceptedExecPolicyContext { + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + enable_shell_wrapper_parsing: false, + }, ); assert_eq!( @@ -523,6 +564,47 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) )); } +#[test] +fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default() { + let policy = PolicyParser::new().build(); + let program = AbsolutePathBuf::try_from(host_absolute_path(&["usr", "bin", "printf"])).unwrap(); + let argv = ["printf".to_string(), "hello".to_string()]; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); + let file_system_sandbox_policy = read_only_file_system_sandbox_policy(); + + let preapproved = evaluate_intercepted_exec_policy( + &policy, + &program, + &argv, + InterceptedExecPolicyContext { + approval_policy, + sandbox_policy: &sandbox_policy, + file_system_sandbox_policy: &file_system_sandbox_policy, + sandbox_permissions: super::approval_sandbox_permissions( + SandboxPermissions::WithAdditionalPermissions, + true, + ), + enable_shell_wrapper_parsing: false, + }, + ); + let fresh_request = evaluate_intercepted_exec_policy( + &policy, + &program, + &argv, + InterceptedExecPolicyContext { + approval_policy, + sandbox_policy: &sandbox_policy, + file_system_sandbox_policy: &file_system_sandbox_policy, + sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, + enable_shell_wrapper_parsing: false, + }, + ); + + assert_eq!(preapproved.decision, Decision::Allow); + assert_eq!(fresh_request.decision, Decision::Prompt); +} + #[test] fn intercepted_exec_policy_rejects_disallowed_host_executable_mapping() { let allowed_git = host_absolute_path(&["usr", "bin", "git"]); @@ -543,10 +625,13 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) &policy, &program, &["git".to_string(), "status".to_string()], - AskForApproval::OnRequest, - &SandboxPolicy::new_read_only_policy(), - SandboxPermissions::UseDefault, - false, + InterceptedExecPolicyContext { + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + enable_shell_wrapper_parsing: false, + }, ); assert!(matches!( @@ -571,9 +656,7 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions network: None, sandbox: SandboxType::None, sandbox_policy: SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from( - &SandboxPolicy::new_read_only_policy(), - ), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), network_sandbox_policy: NetworkSandboxPolicy::Restricted, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, @@ -585,7 +668,7 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions ..Default::default() }), codex_linux_sandbox_exe: None, - use_linux_sandbox_bwrap: false, + use_legacy_landlock: false, }; let prepared = executor @@ -625,7 +708,7 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() network: None, sandbox: SandboxType::None, sandbox_policy: SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), network_sandbox_policy: NetworkSandboxPolicy::Enabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, @@ -634,15 +717,13 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() sandbox_policy_cwd: cwd.to_path_buf(), macos_seatbelt_profile_extensions: None, codex_linux_sandbox_exe: None, - use_linux_sandbox_bwrap: false, + use_legacy_landlock: false, }; let permissions = Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), - file_system_sandbox_policy: codex_protocol::permissions::FileSystemSandboxPolicy::from( - &SandboxPolicy::new_read_only_policy(), - ), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), network_sandbox_policy: codex_protocol::permissions::NetworkSandboxPolicy::Restricted, network: None, allow_login_shell: true, @@ -701,7 +782,7 @@ async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_mac network: None, sandbox: SandboxType::None, sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, @@ -713,7 +794,7 @@ async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_mac ..Default::default() }), codex_linux_sandbox_exe: None, - use_linux_sandbox_bwrap: false, + use_legacy_landlock: false, }; let prepared = executor diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 48052a5371..8d15c96c65 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -54,6 +54,8 @@ pub struct UnifiedExecRequest { pub tty: bool, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, + #[cfg(unix)] + pub additional_permissions_preapproved: bool, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index fef4fa3737..16fd5b1a7c 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -7,6 +7,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::error::CodexErr; +#[cfg(test)] use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; @@ -17,6 +18,7 @@ use crate::tools::network_approval::NetworkApprovalSpec; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkApprovalContext; +use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; @@ -158,20 +160,22 @@ impl ExecApprovalRequirement { } /// - Never, OnFailure: do not ask -/// - OnRequest: ask unless sandbox policy is DangerFullAccess -/// - Reject: ask unless sandbox policy is DangerFullAccess, but auto-reject +/// - OnRequest: ask unless filesystem access is unrestricted +/// - Reject: ask unless filesystem access is unrestricted, but auto-reject /// when `sandbox_approval` rejection is enabled. /// - UnlessTrusted: always ask pub(crate) fn default_exec_approval_requirement( policy: AskForApproval, - sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, ) -> ExecApprovalRequirement { let needs_approval = match policy { AskForApproval::Never | AskForApproval::OnFailure => false, - AskForApproval::OnRequest | AskForApproval::Reject(_) => !matches!( - sandbox_policy, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ), + AskForApproval::OnRequest | AskForApproval::Reject(_) => { + matches!( + file_system_sandbox_policy.kind, + FileSystemSandboxKind::Restricted + ) + } AskForApproval::UnlessTrusted => true, }; @@ -326,7 +330,7 @@ pub(crate) struct SandboxAttempt<'a> { pub(crate) manager: &'a SandboxManager, pub(crate) sandbox_cwd: &'a Path, pub codex_linux_sandbox_exe: Option<&'a std::path::PathBuf>, - pub use_linux_sandbox_bwrap: bool, + pub use_legacy_landlock: bool, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, } @@ -349,118 +353,12 @@ impl<'a> SandboxAttempt<'a> { #[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: None, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe, - use_linux_sandbox_bwrap: self.use_linux_sandbox_bwrap, + use_legacy_landlock: self.use_legacy_landlock, windows_sandbox_level: self.windows_sandbox_level, }) } } #[cfg(test)] -mod tests { - use super::*; - use crate::sandboxing::SandboxPermissions; - use codex_protocol::protocol::NetworkAccess; - use codex_protocol::protocol::RejectConfig; - use pretty_assertions::assert_eq; - - #[test] - fn external_sandbox_skips_exec_approval_on_request() { - assert_eq!( - default_exec_approval_requirement( - AskForApproval::OnRequest, - &SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - }, - ), - ExecApprovalRequirement::Skip { - bypass_sandbox: false, - proposed_execpolicy_amendment: None, - } - ); - } - - #[test] - fn restricted_sandbox_requires_exec_approval_on_request() { - assert_eq!( - default_exec_approval_requirement( - AskForApproval::OnRequest, - &SandboxPolicy::new_read_only_policy() - ), - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: None, - } - ); - } - - #[test] - fn default_exec_approval_requirement_rejects_sandbox_prompt_when_configured() { - let policy = AskForApproval::Reject(RejectConfig { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - }); - - let requirement = - default_exec_approval_requirement(policy, &SandboxPolicy::new_read_only_policy()); - - assert_eq!( - requirement, - ExecApprovalRequirement::Forbidden { - reason: "approval policy rejected sandbox approval prompt".to_string(), - } - ); - } - - #[test] - fn default_exec_approval_requirement_keeps_prompt_when_sandbox_rejection_is_disabled() { - let policy = AskForApproval::Reject(RejectConfig { - sandbox_approval: false, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }); - - let requirement = - default_exec_approval_requirement(policy, &SandboxPolicy::new_read_only_policy()); - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: None, - } - ); - } - - #[test] - fn additional_permissions_allow_bypass_sandbox_first_attempt_when_execpolicy_skips() { - assert_eq!( - sandbox_override_for_first_attempt( - SandboxPermissions::WithAdditionalPermissions, - &ExecApprovalRequirement::Skip { - bypass_sandbox: true, - proposed_execpolicy_amendment: None, - }, - ), - SandboxOverride::BypassSandboxFirstAttempt - ); - } - - #[test] - fn guardian_bypasses_sandbox_for_explicit_escalation_on_first_attempt() { - assert_eq!( - sandbox_override_for_first_attempt( - SandboxPermissions::RequireEscalated, - &ExecApprovalRequirement::Skip { - bypass_sandbox: false, - proposed_execpolicy_amendment: None, - }, - ), - SandboxOverride::BypassSandboxFirstAttempt - ); - } -} +#[path = "sandboxing_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs new file mode 100644 index 0000000000..cf68307ada --- /dev/null +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -0,0 +1,110 @@ +use super::*; +use crate::sandboxing::SandboxPermissions; +use codex_protocol::protocol::NetworkAccess; +use codex_protocol::protocol::RejectConfig; +use pretty_assertions::assert_eq; + +#[test] +fn external_sandbox_skips_exec_approval_on_request() { + let sandbox_policy = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }; + assert_eq!( + default_exec_approval_requirement( + AskForApproval::OnRequest, + &FileSystemSandboxPolicy::from(&sandbox_policy), + ), + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + } + ); +} + +#[test] +fn restricted_sandbox_requires_exec_approval_on_request() { + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + assert_eq!( + default_exec_approval_requirement( + AskForApproval::OnRequest, + &FileSystemSandboxPolicy::from(&sandbox_policy) + ), + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + } + ); +} + +#[test] +fn default_exec_approval_requirement_rejects_sandbox_prompt_when_configured() { + let policy = AskForApproval::Reject(RejectConfig { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }); + + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let requirement = + default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::from(&sandbox_policy)); + + assert_eq!( + requirement, + ExecApprovalRequirement::Forbidden { + reason: "approval policy rejected sandbox approval prompt".to_string(), + } + ); +} + +#[test] +fn default_exec_approval_requirement_keeps_prompt_when_sandbox_rejection_is_disabled() { + let policy = AskForApproval::Reject(RejectConfig { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }); + + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let requirement = + default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::from(&sandbox_policy)); + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + } + ); +} + +#[test] +fn additional_permissions_allow_bypass_sandbox_first_attempt_when_execpolicy_skips() { + assert_eq!( + sandbox_override_for_first_attempt( + SandboxPermissions::WithAdditionalPermissions, + &ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + }, + ), + SandboxOverride::BypassSandboxFirstAttempt + ); +} + +#[test] +fn guardian_bypasses_sandbox_for_explicit_escalation_on_first_attempt() { + assert_eq!( + sandbox_override_for_first_attempt( + SandboxPermissions::RequireEscalated, + &ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + ), + SandboxOverride::BypassSandboxFirstAttempt + ); +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index cf9eaa32f7..ab41a3b36e 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -7,11 +7,19 @@ use crate::features::Feature; use crate::features::Features; use crate::mcp_connection_manager::ToolInfo; use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; +use crate::original_image_detail::can_request_original_image_detail; +use crate::tools::code_mode::DEFAULT_WAIT_YIELD_TIME_MS; use crate::tools::code_mode::PUBLIC_TOOL_NAME; +use crate::tools::code_mode::WAIT_TOOL_NAME; use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; +use crate::tools::discoverable::DiscoverablePluginInfo; +use crate::tools::discoverable::DiscoverableTool; +use crate::tools::discoverable::DiscoverableToolAction; +use crate::tools::discoverable::DiscoverableToolType; use crate::tools::handlers::PLAN_TOOL; -use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT; -use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME; +use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT; +use crate::tools::handlers::TOOL_SEARCH_TOOL_NAME; +use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME; use crate::tools::handlers::agent_jobs::BatchJobHandler; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; @@ -21,6 +29,7 @@ use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::request_permissions_tool_description; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; +use crate::tools::registry::tool_handler_key; use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolSpec; @@ -40,8 +49,10 @@ use serde_json::json; use std::collections::BTreeMap; use std::collections::HashMap; -const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str = +const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str = include_str!("../../templates/search_tool/tool_description.md"); +const TOOL_SUGGEST_DESCRIPTION_TEMPLATE: &str = + include_str!("../../templates/search_tool/tool_suggest_description.md"); const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"]; fn unified_exec_output_schema() -> JsonValue { @@ -103,11 +114,13 @@ pub(crate) struct ToolsConfig { pub image_gen_tool: bool, pub agent_roles: BTreeMap, pub search_tool: bool, + pub tool_suggest: bool, pub request_permission_enabled: bool, pub request_permissions_tool_enabled: bool, pub code_mode_enabled: bool, pub js_repl_enabled: bool, pub js_repl_tools_only: bool, + pub can_request_original_image_detail: bool, pub collab_tools: bool, pub artifact_tools: bool, pub request_user_input: bool, @@ -145,6 +158,8 @@ impl ToolsConfig { let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); let include_search_tool = features.enabled(Feature::Apps); + let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest); + let include_original_image_detail = can_request_original_image_detail(features, model_info); let include_artifact_tools = features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime(); let include_image_gen_tool = @@ -211,11 +226,13 @@ impl ToolsConfig { image_gen_tool: include_image_gen_tool, agent_roles: BTreeMap::new(), search_tool: include_search_tool, + tool_suggest: include_tool_suggest, request_permission_enabled, request_permissions_tool_enabled, code_mode_enabled: include_code_mode, js_repl_enabled: include_js_repl, js_repl_tools_only: include_js_repl_tools_only, + can_request_original_image_detail: include_original_image_detail, collab_tools: include_collab_tools, artifact_tools: include_artifact_tools, request_user_input: include_request_user_input, @@ -515,6 +532,7 @@ fn create_exec_command_tool(allow_login_shell: bool, request_permission_enabled: "Runs a command in a PTY, returning output or a session ID for ongoing interaction." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["cmd".to_string()]), @@ -563,6 +581,7 @@ fn create_write_stdin_tool() -> ToolSpec { "Writes characters to an existing unified exec session and returns recent output." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["session_id".to_string()]), @@ -572,6 +591,55 @@ fn create_write_stdin_tool() -> ToolSpec { }) } +fn create_exec_wait_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "session_id".to_string(), + JsonSchema::Number { + description: Some("Identifier of the running exec session.".to_string()), + }, + ), + ( + "yield_time_ms".to_string(), + JsonSchema::Number { + description: Some( + "How long to wait (in milliseconds) for more output before yielding again." + .to_string(), + ), + }, + ), + ( + "max_tokens".to_string(), + JsonSchema::Number { + description: Some( + "Maximum number of output tokens to return for this wait call.".to_string(), + ), + }, + ), + ( + "terminate".to_string(), + JsonSchema::Boolean { + description: Some("Whether to terminate the running exec session.".to_string()), + }, + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: WAIT_TOOL_NAME.to_string(), + description: format!( + "Waits on a yielded `{PUBLIC_TOOL_NAME}` session and returns new output or completion." + ), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["session_id".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + defer_loading: None, + }) +} + fn create_shell_tool(request_permission_enabled: bool) -> ToolSpec { let mut properties = BTreeMap::from([ ( @@ -617,6 +685,7 @@ Examples of valid command strings: name: "shell".to_string(), description, strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["command".to_string()]), @@ -685,6 +754,7 @@ Examples of valid command strings: name: "shell_command".to_string(), description, strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["command".to_string()]), @@ -694,20 +764,31 @@ Examples of valid command strings: }) } -fn create_view_image_tool() -> ToolSpec { +fn create_view_image_tool(can_request_original_image_detail: bool) -> ToolSpec { // Support only local filesystem path. - let properties = BTreeMap::from([( + let mut properties = BTreeMap::from([( "path".to_string(), JsonSchema::String { description: Some("Local filesystem path to an image file".to_string()), }, )]); + if can_request_original_image_detail { + properties.insert( + "detail".to_string(), + JsonSchema::String { + description: Some( + "Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(), + ), + }, + ); + } ToolSpec::Function(ResponsesApiTool { name: VIEW_IMAGE_TOOL_NAME.to_string(), description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags)." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["path".to_string()]), @@ -823,7 +904,10 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { name: "spawn_agent".to_string(), description: format!( r#" - Only use `spawn_agent` if and only if the user explicitly asked for sub-agents or parallel agent work. Spawn a sub-agent for a well-scoped task. Returns the agent id (and user-facing nickname when available) to use to communicate with this agent. This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool. + Only use `spawn_agent` if and only if the user explicitly asks for sub-agents, delegation, or parallel agent work. + Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn. + Agent-role guidance below only helps choose which agent to use after spawning is already authorized; it never authorizes spawning by itself. + Spawn a sub-agent for a well-scoped task. Returns the agent id (and user-facing nickname when available) to use to communicate with this agent. This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool. {available_models_description} ### When to delegate vs. do the subtask yourself @@ -856,6 +940,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { - The key is to find opportunities to spawn multiple independent subtasks in parallel within the same round, while ensuring each subtask is well-defined, self-contained, and materially advances the main task."# ), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: None, @@ -962,6 +1047,7 @@ fn create_spawn_agents_on_csv_tool() -> ToolSpec { description: "Process a CSV by spawning one worker sub-agent per row. The instruction string is a template where `{column}` placeholders are replaced with row values. Each worker must call `report_agent_job_result` with a JSON object (matching `output_schema` when provided); missing reports are treated as failures. This call blocks until all rows finish and automatically exports results to `output_csv_path` (or a default path)." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["csv_path".to_string(), "instruction".to_string()]), @@ -1008,6 +1094,7 @@ fn create_report_agent_job_result_tool() -> ToolSpec { "Worker-only tool to report a result for an agent job item. Main agents should not call this." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec![ @@ -1055,6 +1142,7 @@ fn create_send_input_tool() -> ToolSpec { description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["id".to_string()]), @@ -1079,6 +1167,7 @@ fn create_resume_agent_tool() -> ToolSpec { "Resume a previously closed agent by id so it can receive send_input and wait calls." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["id".to_string()]), @@ -1114,6 +1203,7 @@ fn create_wait_tool() -> ToolSpec { description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["ids".to_string()]), @@ -1200,6 +1290,7 @@ fn create_request_user_input_tool( collaboration_modes_config.default_mode_request_user_input, ), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["questions".to_string()]), @@ -1225,6 +1316,7 @@ fn create_request_permissions_tool() -> ToolSpec { name: "request_permissions".to_string(), description: request_permissions_tool_description(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["permissions".to_string()]), @@ -1247,6 +1339,7 @@ fn create_close_agent_tool() -> ToolSpec { name: "close_agent".to_string(), description: "Close an agent when it is no longer needed and return its last known status. Don't keep agents open for too long if they are not needed anymore.".to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["id".to_string()]), @@ -1315,6 +1408,7 @@ fn create_test_sync_tool() -> ToolSpec { name: "test_sync_tool".to_string(), description: "Internal synchronization helper used by Codex integration tests.".to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: None, @@ -1367,6 +1461,7 @@ fn create_grep_files_tool() -> ToolSpec { time." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["pattern".to_string()]), @@ -1376,7 +1471,7 @@ fn create_grep_files_tool() -> ToolSpec { }) } -fn create_search_tool_bm25_tool(app_tools: &HashMap) -> ToolSpec { +fn create_tool_search_tool(app_tools: &HashMap) -> ToolSpec { let properties = BTreeMap::from([ ( "query".to_string(), @@ -1388,7 +1483,7 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap) -> ToolSp "limit".to_string(), JsonSchema::Number { description: Some(format!( - "Maximum number of tools to return (defaults to {SEARCH_TOOL_BM25_DEFAULT_LIMIT})." + "Maximum number of tools to return (defaults to {TOOL_SEARCH_DEFAULT_LIMIT})." )), }, ), @@ -1402,26 +1497,151 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap) -> ToolSp let app_names = app_names.join(", "); let description = if app_names.is_empty() { - SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE + TOOL_SEARCH_DESCRIPTION_TEMPLATE .replace("({{app_names}})", "(None currently enabled)") .replace("{{app_names}}", "available apps") } else { - SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str()) + TOOL_SEARCH_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str()) }; - ToolSpec::Function(ResponsesApiTool { - name: SEARCH_TOOL_BM25_TOOL_NAME.to_string(), + ToolSpec::ToolSearch { + execution: "client".to_string(), description, - strict: false, parameters: JsonSchema::Object { properties, required: Some(vec!["query".to_string()]), additional_properties: Some(false.into()), }, + } +} + +fn create_tool_suggest_tool(discoverable_tools: &[DiscoverableTool]) -> ToolSpec { + let discoverable_tool_ids = discoverable_tools + .iter() + .map(DiscoverableTool::id) + .collect::>() + .join(", "); + let properties = BTreeMap::from([ + ( + "tool_type".to_string(), + JsonSchema::String { + description: Some( + "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"." + .to_string(), + ), + }, + ), + ( + "action_type".to_string(), + JsonSchema::String { + description: Some( + "Suggested action for the tool. Use \"install\" or \"enable\".".to_string(), + ), + }, + ), + ( + "tool_id".to_string(), + JsonSchema::String { + description: Some(format!( + "Connector or plugin id to suggest. Must be one of: {discoverable_tool_ids}." + )), + }, + ), + ( + "suggest_reason".to_string(), + JsonSchema::String { + description: Some( + "Concise one-line user-facing reason why this tool can help with the current request." + .to_string(), + ), + }, + ), + ]); + let description = TOOL_SUGGEST_DESCRIPTION_TEMPLATE.replace( + "{{discoverable_tools}}", + format_discoverable_tools(discoverable_tools).as_str(), + ); + + ToolSpec::Function(ResponsesApiTool { + name: TOOL_SUGGEST_TOOL_NAME.to_string(), + description, + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: Some(vec![ + "tool_type".to_string(), + "action_type".to_string(), + "tool_id".to_string(), + "suggest_reason".to_string(), + ]), + additional_properties: Some(false.into()), + }, output_schema: None, }) } +fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String { + let mut discoverable_tools = discoverable_tools.to_vec(); + discoverable_tools.sort_by(|left, right| { + left.name() + .cmp(right.name()) + .then_with(|| left.id().cmp(right.id())) + }); + + discoverable_tools + .into_iter() + .map(|tool| { + let description = tool + .description() + .filter(|description| !description.trim().is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| match &tool { + DiscoverableTool::Connector(_) => "No description provided.".to_string(), + DiscoverableTool::Plugin(plugin) => format_plugin_summary(plugin.as_ref()), + }); + let default_action = match tool.tool_type() { + DiscoverableToolType::Connector => DiscoverableToolAction::Install, + DiscoverableToolType::Plugin => DiscoverableToolAction::Enable, + }; + format!( + "- {} (id: `{}`, type: {}, action: {}): {}", + tool.name(), + tool.id(), + tool.tool_type().as_str(), + default_action.as_str(), + description + ) + }) + .collect::>() + .join("\n") +} + +fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String { + let mut details = Vec::new(); + if plugin.has_skills { + details.push("skills".to_string()); + } + if !plugin.mcp_server_names.is_empty() { + details.push(format!( + "MCP servers: {}", + plugin.mcp_server_names.join(", ") + )); + } + if !plugin.app_connector_ids.is_empty() { + details.push(format!( + "app connectors: {}", + plugin.app_connector_ids.join(", ") + )); + } + + if details.is_empty() { + "No description provided.".to_string() + } else { + details.join("; ") + } +} + fn create_read_file_tool() -> ToolSpec { let indentation_properties = BTreeMap::from([ ( @@ -1517,6 +1737,7 @@ fn create_read_file_tool() -> ToolSpec { "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["file_path".to_string()]), @@ -1564,6 +1785,7 @@ fn create_list_dir_tool() -> ToolSpec { "Lists entries in a local directory with 1-indexed entry numbers and simple type labels." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["dir_path".to_string()]), @@ -1639,6 +1861,7 @@ fn create_js_repl_reset_tool() -> ToolSpec { "Restarts the js_repl kernel for this run and clears persisted top-level bindings." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties: BTreeMap::new(), required: None, @@ -1660,7 +1883,7 @@ source: /[\s\S]+/ enabled_tool_names.join(", ") }; let description = format!( - "Runs JavaScript in a Node-backed `node:vm` context. This is a freeform tool: send raw JavaScript source text (no JSON/quotes/markdown fences). Direct tool calls remain available while `{PUBLIC_TOOL_NAME}` is enabled. Inside JavaScript, import nested tools from `tools.js`, for example `import {{ exec_command }} from \"tools.js\"` or `import {{ ALL_TOOLS }} from \"tools.js\"` to inspect the available `{{ module, name, description }}` entries. Namespaced tools are also available from `tools/.js`; MCP tools use `tools/mcp/.js`, for example `import {{ append_notebook_logs_chart }} from \"tools/mcp/ologs.js\"`. Nested tool calls resolve to their code-mode result values. Import `{{ output_text, output_image, set_max_output_tokens_per_exec_call, store, load }}` from `\"@openai/code_mode\"` (or `\"openai/code_mode\"`); `output_text(value)` surfaces text back to the model and stringifies non-string objects when possible, `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs, `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, `load(key)` returns a cloned stored value or `undefined`, and `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate the final Rust-side result of the current `{PUBLIC_TOOL_NAME}` execution. The default is `10000`. This guards the overall `{PUBLIC_TOOL_NAME}` output, not individual nested tool invocations. The returned content starts with a separate `Script completed` or `Script failed` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker. Function tools require JSON object arguments. Freeform tools require raw strings. `add_content(value)` remains available for compatibility with a content item, content-item array, or string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`. Only content passed to `output_text(...)`, `output_image(...)`, or `add_content(value)` is surfaced back to the model. Enabled nested tools: {enabled_list}." + "Runs JavaScript in a Node-backed `node:vm` context. This is a freeform tool: send raw JavaScript source text (no JSON/quotes/markdown fences). Direct tool calls remain available while `{PUBLIC_TOOL_NAME}` is enabled. Inside JavaScript, import nested tools from `tools.js`, for example `import {{ exec_command }} from \"tools.js\"` or `import {{ ALL_TOOLS }} from \"tools.js\"` to inspect the available `{{ module, name, description }}` entries. Namespaced tools are also available from `tools/.js`; MCP tools use `tools/mcp/.js`, for example `import {{ append_notebook_logs_chart }} from \"tools/mcp/ologs.js\"`. Nested tool calls resolve to their code-mode result values. Import `{{ output_text, output_image, set_max_output_tokens_per_exec_call, set_yield_time, store, load }}` from `\"@openai/code_mode\"` (or `\"openai/code_mode\"`); `output_text(value)` surfaces text back to the model and stringifies non-string objects when possible, `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs, `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, `load(key)` returns a cloned stored value or `undefined`, `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate direct `{PUBLIC_TOOL_NAME}` returns, and `{WAIT_TOOL_NAME}` uses its own `max_tokens` argument with a default of `10000`. `set_yield_time(value)` asks `{PUBLIC_TOOL_NAME}` to return early if the script is still running after that many milliseconds so `{WAIT_TOOL_NAME}` can resume it later. The default wait timeout for `{WAIT_TOOL_NAME}` is {DEFAULT_WAIT_YIELD_TIME_MS}. The returned content starts with a separate `Script completed`, `Script failed`, or `Script running with session ID …` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker. Function tools require JSON object arguments. Freeform tools require raw strings. `add_content(value)` remains available for compatibility with a content item, content-item array, or string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`. Only content passed to `output_text(...)`, `output_image(...)`, or `add_content(value)` is surfaced back to the model. Enabled nested tools: {enabled_list}." ); ToolSpec::Freeform(FreeformTool { @@ -1675,7 +1898,9 @@ source: /[\s\S]+/ } fn is_code_mode_nested_tool(spec: &ToolSpec) -> bool { - spec.name() != PUBLIC_TOOL_NAME && matches!(spec, ToolSpec::Function(_) | ToolSpec::Freeform(_)) + spec.name() != PUBLIC_TOOL_NAME + && spec.name() != WAIT_TOOL_NAME + && matches!(spec, ToolSpec::Function(_) | ToolSpec::Freeform(_)) } fn create_list_mcp_resources_tool() -> ToolSpec { @@ -1704,6 +1929,7 @@ fn create_list_mcp_resources_tool() -> ToolSpec { name: "list_mcp_resources".to_string(), description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: None, @@ -1739,6 +1965,7 @@ fn create_list_mcp_resource_templates_tool() -> ToolSpec { name: "list_mcp_resource_templates".to_string(), description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: None, @@ -1776,6 +2003,7 @@ fn create_read_mcp_resource_tool() -> ToolSpec { "Read a specific resource from an MCP server given the server name and resource URI." .to_string(), strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, required: Some(vec!["server".to_string(), "uri".to_string()]), @@ -1825,6 +2053,59 @@ pub(crate) fn mcp_tool_to_openai_tool( fully_qualified_name: String, tool: rmcp::model::Tool, ) -> Result { + let (description, input_schema, output_schema) = mcp_tool_to_openai_tool_parts(tool)?; + + Ok(ResponsesApiTool { + name: fully_qualified_name, + description, + strict: false, + defer_loading: None, + parameters: input_schema, + output_schema, + }) +} + +pub(crate) fn mcp_tool_to_deferred_openai_tool( + name: String, + tool: rmcp::model::Tool, +) -> Result { + let (description, input_schema, _) = mcp_tool_to_openai_tool_parts(tool)?; + + Ok(ResponsesApiTool { + name, + description, + strict: false, + defer_loading: Some(true), + parameters: input_schema, + output_schema: None, + }) +} + +fn dynamic_tool_to_openai_tool( + tool: &DynamicToolSpec, +) -> Result { + let input_schema = parse_tool_input_schema(&tool.input_schema)?; + + Ok(ResponsesApiTool { + name: tool.name.clone(), + description: tool.description.clone(), + strict: false, + defer_loading: None, + parameters: input_schema, + output_schema: None, + }) +} + +/// Parse the tool input_schema or return an error for invalid schema +pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { + let mut input_schema = input_schema.clone(); + sanitize_json_schema(&mut input_schema); + serde_json::from_value::(input_schema) +} + +fn mcp_tool_to_openai_tool_parts( + tool: rmcp::model::Tool, +) -> Result<(String, JsonSchema, Option), serde_json::Error> { let rmcp::model::Tool { description, input_schema, @@ -1859,35 +2140,9 @@ pub(crate) fn mcp_tool_to_openai_tool( let output_schema = Some(mcp_call_tool_result_output_schema( structured_content_schema, )); + let description = description.map(Into::into).unwrap_or_default(); - Ok(ResponsesApiTool { - name: fully_qualified_name, - description: description.map(Into::into).unwrap_or_default(), - strict: false, - parameters: input_schema, - output_schema, - }) -} - -fn dynamic_tool_to_openai_tool( - tool: &DynamicToolSpec, -) -> Result { - let input_schema = parse_tool_input_schema(&tool.input_schema)?; - - Ok(ResponsesApiTool { - name: tool.name.clone(), - description: tool.description.clone(), - strict: false, - parameters: input_schema, - output_schema: None, - }) -} - -/// Parse the tool input_schema or return an error for invalid schema -pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { - let mut input_schema = input_schema.clone(); - sanitize_json_schema(&mut input_schema); - serde_json::from_value::(input_schema) + Ok((description, input_schema, output_schema)) } fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> JsonValue { @@ -2021,15 +2276,27 @@ fn sanitize_json_schema(value: &mut JsonValue) { } /// Builds the tool registry builder while collecting tool specs for later serialization. +#[cfg(test)] pub(crate) fn build_specs( config: &ToolsConfig, mcp_tools: Option>, app_tools: Option>, dynamic_tools: &[DynamicToolSpec], +) -> ToolRegistryBuilder { + build_specs_with_discoverable_tools(config, mcp_tools, app_tools, None, dynamic_tools) +} + +pub(crate) fn build_specs_with_discoverable_tools( + config: &ToolsConfig, + mcp_tools: Option>, + app_tools: Option>, + discoverable_tools: Option>, + dynamic_tools: &[DynamicToolSpec], ) -> ToolRegistryBuilder { use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::ArtifactsHandler; - use crate::tools::handlers::CodeModeHandler; + use crate::tools::handlers::CodeModeExecuteHandler; + use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::JsReplHandler; @@ -2042,10 +2309,11 @@ pub(crate) fn build_specs( use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; - use crate::tools::handlers::SearchToolBm25Handler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; + use crate::tools::handlers::ToolSearchHandler; + use crate::tools::handlers::ToolSuggestHandler; use crate::tools::handlers::UnifiedExecHandler; use crate::tools::handlers::ViewImageHandler; use std::sync::Arc; @@ -2065,8 +2333,9 @@ pub(crate) fn build_specs( let request_user_input_handler = Arc::new(RequestUserInputHandler { default_mode_request_user_input: config.default_mode_request_user_input, }); - let search_tool_handler = Arc::new(SearchToolBm25Handler); - let code_mode_handler = Arc::new(CodeModeHandler); + let tool_suggest_handler = Arc::new(ToolSuggestHandler); + let code_mode_handler = Arc::new(CodeModeExecuteHandler); + let code_mode_wait_handler = Arc::new(CodeModeWaitHandler); let js_repl_handler = Arc::new(JsReplHandler); let js_repl_reset_handler = Arc::new(JsReplResetHandler); let artifacts_handler = Arc::new(ArtifactsHandler); @@ -2074,10 +2343,11 @@ pub(crate) fn build_specs( if config.code_mode_enabled { let nested_config = config.for_code_mode_nested_tools(); - let (nested_specs, _) = build_specs( + let (nested_specs, _) = build_specs_with_discoverable_tools( &nested_config, mcp_tools.clone(), app_tools.clone(), + None, dynamic_tools, ) .build(); @@ -2096,6 +2366,13 @@ pub(crate) fn build_specs( config.code_mode_enabled, ); builder.register_handler(PUBLIC_TOOL_NAME, code_mode_handler); + push_tool_spec( + &mut builder, + create_exec_wait_tool(), + false, + config.code_mode_enabled, + ); + builder.register_handler(WAIT_TOOL_NAME, code_mode_wait_handler); } match &config.shell_type { @@ -2223,15 +2500,33 @@ pub(crate) fn build_specs( builder.register_handler("request_permissions", request_permissions_handler); } - if config.search_tool { - let app_tools = app_tools.unwrap_or_default(); + if config.search_tool + && let Some(app_tools) = app_tools + { + let search_tool_handler = Arc::new(ToolSearchHandler::new(app_tools.clone())); push_tool_spec( &mut builder, - create_search_tool_bm25_tool(&app_tools), + create_tool_search_tool(&app_tools), true, config.code_mode_enabled, ); - builder.register_handler(SEARCH_TOOL_BM25_TOOL_NAME, search_tool_handler); + builder.register_handler(TOOL_SEARCH_TOOL_NAME, search_tool_handler); + + for tool in app_tools.values() { + let alias_name = + tool_handler_key(tool.tool_name.as_str(), Some(tool.tool_namespace.as_str())); + + builder.register_handler(alias_name, mcp_handler.clone()); + } + } + + if config.tool_suggest + && let Some(discoverable_tools) = discoverable_tools + .as_ref() + .filter(|tools| !tools.is_empty()) + { + builder.push_spec_with_parallel_support(create_tool_suggest_tool(discoverable_tools), true); + builder.register_handler(TOOL_SUGGEST_TOOL_NAME, tool_suggest_handler); } if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { @@ -2366,7 +2661,7 @@ pub(crate) fn build_specs( push_tool_spec( &mut builder, - create_view_image_tool(), + create_view_image_tool(config.can_request_original_image_detail), true, config.code_mode_enabled, ); @@ -2489,2101 +2784,5 @@ pub(crate) fn build_specs( } #[cfg(test)] -mod tests { - use crate::client_common::tools::FreeformTool; - use crate::config::test_config; - use crate::models_manager::manager::ModelsManager; - use crate::models_manager::model_info::with_config_overrides; - use crate::tools::registry::ConfiguredToolSpec; - use codex_protocol::openai_models::InputModality; - use codex_protocol::openai_models::ModelInfo; - use codex_protocol::openai_models::ModelsResponse; - use pretty_assertions::assert_eq; - - use super::*; - - fn mcp_tool( - name: &str, - description: &str, - input_schema: serde_json::Value, - ) -> rmcp::model::Tool { - rmcp::model::Tool { - name: name.to_string().into(), - title: None, - description: Some(description.to_string().into()), - input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - } - } - - #[test] - fn mcp_tool_to_openai_tool_inserts_empty_properties() { - let mut schema = rmcp::model::JsonObject::new(); - schema.insert("type".to_string(), serde_json::json!("object")); - - let tool = rmcp::model::Tool { - name: "no_props".to_string().into(), - title: None, - description: Some("No properties".to_string().into()), - input_schema: std::sync::Arc::new(schema), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }; - - let openai_tool = - mcp_tool_to_openai_tool("server/no_props".to_string(), tool).expect("convert tool"); - let parameters = serde_json::to_value(openai_tool.parameters).expect("serialize schema"); - - assert_eq!(parameters.get("properties"), Some(&serde_json::json!({}))); - } - - #[test] - fn mcp_tool_to_openai_tool_preserves_top_level_output_schema() { - let mut input_schema = rmcp::model::JsonObject::new(); - input_schema.insert("type".to_string(), serde_json::json!("object")); - - let mut output_schema = rmcp::model::JsonObject::new(); - output_schema.insert( - "properties".to_string(), - serde_json::json!({ - "result": { - "properties": { - "nested": {} - } - } - }), - ); - output_schema.insert("required".to_string(), serde_json::json!(["result"])); - - let tool = rmcp::model::Tool { - name: "with_output".to_string().into(), - title: None, - description: Some("Has output schema".to_string().into()), - input_schema: std::sync::Arc::new(input_schema), - output_schema: Some(std::sync::Arc::new(output_schema)), - annotations: None, - execution: None, - icons: None, - meta: None, - }; - - let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_output".to_string(), tool) - .expect("convert tool"); - - assert_eq!( - openai_tool.output_schema, - Some(serde_json::json!({ - "type": "object", - "properties": { - "content": { - "type": "array", - "items": {} - }, - "structuredContent": { - "properties": { - "result": { - "properties": { - "nested": {} - } - } - }, - "required": ["result"] - }, - "isError": { - "type": "boolean" - }, - "_meta": {} - }, - "required": ["content"], - "additionalProperties": false - })) - ); - } - - #[test] - fn mcp_tool_to_openai_tool_preserves_output_schema_without_inferred_type() { - let mut input_schema = rmcp::model::JsonObject::new(); - input_schema.insert("type".to_string(), serde_json::json!("object")); - - let mut output_schema = rmcp::model::JsonObject::new(); - output_schema.insert("enum".to_string(), serde_json::json!(["ok", "error"])); - - let tool = rmcp::model::Tool { - name: "with_enum_output".to_string().into(), - title: None, - description: Some("Has enum output schema".to_string().into()), - input_schema: std::sync::Arc::new(input_schema), - output_schema: Some(std::sync::Arc::new(output_schema)), - annotations: None, - execution: None, - icons: None, - meta: None, - }; - - let openai_tool = - mcp_tool_to_openai_tool("mcp__server__with_enum_output".to_string(), tool) - .expect("convert tool"); - - assert_eq!( - openai_tool.output_schema, - Some(serde_json::json!({ - "type": "object", - "properties": { - "content": { - "type": "array", - "items": {} - }, - "structuredContent": { - "enum": ["ok", "error"] - }, - "isError": { - "type": "boolean" - }, - "_meta": {} - }, - "required": ["content"], - "additionalProperties": false - })) - ); - } - - fn tool_name(tool: &ToolSpec) -> &str { - match tool { - ToolSpec::Function(ResponsesApiTool { name, .. }) => name, - ToolSpec::LocalShell {} => "local_shell", - ToolSpec::ImageGeneration { .. } => "image_generation", - ToolSpec::WebSearch { .. } => "web_search", - ToolSpec::Freeform(FreeformTool { name, .. }) => name, - } - } - - // Avoid order-based assertions; compare via set containment instead. - fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[&str]) { - use std::collections::HashSet; - let mut names = HashSet::new(); - let mut duplicates = Vec::new(); - for name in tools.iter().map(|t| tool_name(&t.spec)) { - if !names.insert(name) { - duplicates.push(name); - } - } - assert!( - duplicates.is_empty(), - "duplicate tool entries detected: {duplicates:?}" - ); - for expected in expected_subset { - assert!( - names.contains(expected), - "expected tool {expected} to be present; had: {names:?}" - ); - } - } - - fn assert_lacks_tool_name(tools: &[ConfiguredToolSpec], expected_absent: &str) { - let names = tools - .iter() - .map(|tool| tool_name(&tool.spec)) - .collect::>(); - assert!( - !names.contains(&expected_absent), - "expected tool {expected_absent} to be absent; had: {names:?}" - ); - } - - fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> { - match config.shell_type { - ConfigShellToolType::Default => Some("shell"), - ConfigShellToolType::Local => Some("local_shell"), - ConfigShellToolType::UnifiedExec => None, - ConfigShellToolType::Disabled => None, - ConfigShellToolType::ShellCommand => Some("shell_command"), - } - } - - fn find_tool<'a>( - tools: &'a [ConfiguredToolSpec], - expected_name: &str, - ) -> &'a ConfiguredToolSpec { - tools - .iter() - .find(|tool| tool_name(&tool.spec) == expected_name) - .unwrap_or_else(|| panic!("expected tool {expected_name}")) - } - - fn strip_descriptions_schema(schema: &mut JsonSchema) { - match schema { - JsonSchema::Boolean { description } - | JsonSchema::String { description } - | JsonSchema::Number { description } => { - *description = None; - } - JsonSchema::Array { items, description } => { - strip_descriptions_schema(items); - *description = None; - } - JsonSchema::Object { - properties, - required: _, - additional_properties, - } => { - for v in properties.values_mut() { - strip_descriptions_schema(v); - } - if let Some(AdditionalProperties::Schema(s)) = additional_properties { - strip_descriptions_schema(s); - } - } - } - } - - fn strip_descriptions_tool(spec: &mut ToolSpec) { - match spec { - ToolSpec::Function(ResponsesApiTool { parameters, .. }) => { - strip_descriptions_schema(parameters); - } - ToolSpec::Freeform(_) - | ToolSpec::LocalShell {} - | ToolSpec::ImageGeneration { .. } - | ToolSpec::WebSearch { .. } => {} - } - } - - fn model_info_from_models_json(slug: &str) -> ModelInfo { - let config = test_config(); - let response: ModelsResponse = - serde_json::from_str(include_str!("../../models.json")).expect("valid models.json"); - let model = response - .models - .into_iter() - .find(|candidate| candidate.slug == slug) - .unwrap_or_else(|| panic!("model slug {slug} is missing from models.json")); - with_config_overrides(model, &config) - } - - #[test] - fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { - let model_info = model_info_from_models_json("gpt-5-codex"); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&config, None, None, &[]).build(); - - // Build actual map name -> spec - use std::collections::BTreeMap; - use std::collections::HashSet; - let mut actual: BTreeMap = BTreeMap::from([]); - let mut duplicate_names = Vec::new(); - for t in &tools { - let name = tool_name(&t.spec).to_string(); - if actual.insert(name.clone(), t.spec.clone()).is_some() { - duplicate_names.push(name); - } - } - assert!( - duplicate_names.is_empty(), - "duplicate tool entries detected: {duplicate_names:?}" - ); - - // Build expected from the same helpers used by the builder. - let mut expected: BTreeMap = BTreeMap::from([]); - for spec in [ - create_exec_command_tool(true, false), - create_write_stdin_tool(), - PLAN_TOOL.clone(), - create_request_user_input_tool(CollaborationModesConfig::default()), - create_apply_patch_freeform_tool(), - ToolSpec::WebSearch { - external_web_access: Some(true), - filters: None, - user_location: None, - search_context_size: None, - search_content_types: None, - }, - create_view_image_tool(), - ] { - expected.insert(tool_name(&spec).to_string(), spec); - } - - if config.request_permission_enabled { - let spec = create_request_permissions_tool(); - expected.insert(tool_name(&spec).to_string(), spec); - } - - // Exact name set match — this is the only test allowed to fail when tools change. - let actual_names: HashSet<_> = actual.keys().cloned().collect(); - let expected_names: HashSet<_> = expected.keys().cloned().collect(); - assert_eq!(actual_names, expected_names, "tool name set mismatch"); - - // Compare specs ignoring human-readable descriptions. - for name in expected.keys() { - let mut a = actual.get(name).expect("present").clone(); - let mut e = expected.get(name).expect("present").clone(); - strip_descriptions_tool(&mut a); - strip_descriptions_tool(&mut e); - assert_eq!(a, e, "spec mismatch for {name}"); - } - } - - #[test] - fn test_build_specs_collab_tools_enabled() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::Collab); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert_contains_tool_names( - &tools, - &["spawn_agent", "send_input", "wait", "close_agent"], - ); - assert_lacks_tool_name(&tools, "spawn_agents_on_csv"); - } - - #[test] - fn test_build_specs_spawn_csv_enables_agent_jobs_and_collab_tools() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::SpawnCsv); - features.normalize_dependencies(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert_contains_tool_names( - &tools, - &[ - "spawn_agent", - "send_input", - "wait", - "close_agent", - "spawn_agents_on_csv", - ], - ); - } - - #[test] - fn test_build_specs_artifact_tool_enabled() { - let mut config = test_config(); - let runtime_root = tempfile::TempDir::new().expect("create temp codex home"); - config.codex_home = runtime_root.path().to_path_buf(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::Artifact); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert_contains_tool_names(&tools, &["artifacts"]); - } - - #[test] - fn test_build_specs_agent_job_worker_tools_enabled() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::SpawnCsv); - features.normalize_dependencies(); - features.enable(Feature::Sqlite); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::SubAgent(SubAgentSource::Other( - "agent_job:test".to_string(), - )), - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert_contains_tool_names( - &tools, - &[ - "spawn_agent", - "send_input", - "resume_agent", - "wait", - "close_agent", - "spawn_agents_on_csv", - "report_agent_job_result", - ], - ); - assert_lacks_tool_name(&tools, "request_user_input"); - } - - #[test] - fn request_user_input_description_reflects_default_mode_feature_flag() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - let request_user_input_tool = find_tool(&tools, "request_user_input"); - assert_eq!( - request_user_input_tool.spec, - create_request_user_input_tool(CollaborationModesConfig::default()) - ); - - features.enable(Feature::DefaultModeRequestUserInput); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - let request_user_input_tool = find_tool(&tools, "request_user_input"); - assert_eq!( - request_user_input_tool.spec, - create_request_user_input_tool(CollaborationModesConfig { - default_mode_request_user_input: true, - }) - ); - } - - #[test] - fn request_permissions_requires_feature_flag() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert_lacks_tool_name(&tools, "request_permissions"); - - let mut features = Features::with_defaults(); - features.enable(Feature::RequestPermissionsTool); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - let request_permissions_tool = find_tool(&tools, "request_permissions"); - assert_eq!( - request_permissions_tool.spec, - create_request_permissions_tool() - ); - } - - #[test] - fn request_permissions_tool_is_independent_from_additional_permissions() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::RequestPermissions); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - - assert_lacks_tool_name(&tools, "request_permissions"); - } - - #[test] - fn get_memory_requires_feature_flag() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.disable(Feature::MemoryTool); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert!( - !tools.iter().any(|t| t.spec.name() == "get_memory"), - "get_memory should be disabled when memory_tool feature is off" - ); - } - - #[test] - fn js_repl_requires_feature_flag() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let features = Features::with_defaults(); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - - assert!( - !tools.iter().any(|tool| tool.spec.name() == "js_repl"), - "js_repl should be disabled when the feature is off" - ); - assert!( - !tools.iter().any(|tool| tool.spec.name() == "js_repl_reset"), - "js_repl_reset should be disabled when the feature is off" - ); - } - - #[test] - fn js_repl_enabled_adds_tools() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::JsRepl); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]); - } - - #[test] - fn image_generation_tools_require_feature_and_supported_model() { - let config = test_config(); - let mut supported_model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5.2", &config); - supported_model_info.slug = "custom/gpt-5.2-variant".to_string(); - let mut unsupported_model_info = supported_model_info.clone(); - unsupported_model_info.input_modalities = vec![InputModality::Text]; - let default_features = Features::with_defaults(); - let mut image_generation_features = default_features.clone(); - image_generation_features.enable(Feature::ImageGeneration); - - let available_models = Vec::new(); - let default_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &default_features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (default_tools, _) = build_specs(&default_tools_config, None, None, &[]).build(); - assert!( - !default_tools - .iter() - .any(|tool| tool.spec.name() == "image_generation"), - "image_generation should be disabled by default" - ); - - let supported_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &image_generation_features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (supported_tools, _) = build_specs(&supported_tools_config, None, None, &[]).build(); - assert_contains_tool_names(&supported_tools, &["image_generation"]); - let image_generation_tool = find_tool(&supported_tools, "image_generation"); - assert_eq!( - serde_json::to_value(&image_generation_tool.spec).expect("serialize image tool"), - serde_json::json!({ - "type": "image_generation", - "output_format": "png" - }) - ); - - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &unsupported_model_info, - available_models: &available_models, - features: &image_generation_features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert!( - !tools - .iter() - .any(|tool| tool.spec.name() == "image_generation"), - "image_generation should be disabled for unsupported models" - ); - } - - #[test] - fn js_repl_freeform_grammar_blocks_common_non_js_prefixes() { - let ToolSpec::Freeform(FreeformTool { format, .. }) = create_js_repl_tool() else { - panic!("js_repl should use a freeform tool spec"); - }; - - assert_eq!(format.syntax, "lark"); - assert!(format.definition.contains("PRAGMA_LINE")); - assert!(format.definition.contains("`[^`]")); - assert!(format.definition.contains("``[^`]")); - assert!(format.definition.contains("PLAIN_JS_SOURCE")); - assert!(format.definition.contains("codex-js-repl:")); - assert!(!format.definition.contains("(?!")); - } - - fn assert_model_tools( - model_slug: &str, - features: &Features, - web_search_mode: Option, - expected_tools: &[&str], - ) { - let _config = test_config(); - let model_info = model_info_from_models_json(model_slug); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features, - web_search_mode, - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - let tool_names = tools.iter().map(|t| t.spec.name()).collect::>(); - assert_eq!(&tool_names, &expected_tools,); - } - - fn assert_default_model_tools( - model_slug: &str, - features: &Features, - web_search_mode: Option, - shell_tool: &'static str, - expected_tail: &[&str], - ) { - let mut expected = if features.enabled(Feature::UnifiedExec) { - vec!["exec_command", "write_stdin"] - } else { - vec![shell_tool] - }; - expected.extend(expected_tail); - assert_model_tools(model_slug, features, web_search_mode, &expected); - } - - #[test] - fn web_search_mode_cached_sets_external_web_access_false() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let features = Features::with_defaults(); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - - let tool = find_tool(&tools, "web_search"); - assert_eq!( - tool.spec, - ToolSpec::WebSearch { - external_web_access: Some(false), - filters: None, - user_location: None, - search_context_size: None, - search_content_types: None, - } - ); - } - - #[test] - fn web_search_mode_live_sets_external_web_access_true() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let features = Features::with_defaults(); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - - let tool = find_tool(&tools, "web_search"); - assert_eq!( - tool.spec, - ToolSpec::WebSearch { - external_web_access: Some(true), - filters: None, - user_location: None, - search_context_size: None, - search_content_types: None, - } - ); - } - - #[test] - fn web_search_config_is_forwarded_to_tool_spec() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let features = Features::with_defaults(); - let web_search_config = WebSearchConfig { - filters: Some(codex_protocol::config_types::WebSearchFilters { - allowed_domains: Some(vec!["example.com".to_string()]), - }), - user_location: Some(codex_protocol::config_types::WebSearchUserLocation { - r#type: codex_protocol::config_types::WebSearchUserLocationType::Approximate, - country: Some("US".to_string()), - region: Some("California".to_string()), - city: Some("San Francisco".to_string()), - timezone: Some("America/Los_Angeles".to_string()), - }), - search_context_size: Some(codex_protocol::config_types::WebSearchContextSize::High), - }; - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - }) - .with_web_search_config(Some(web_search_config.clone())); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - - let tool = find_tool(&tools, "web_search"); - assert_eq!( - tool.spec, - ToolSpec::WebSearch { - external_web_access: Some(true), - filters: web_search_config - .filters - .map(crate::client_common::tools::ResponsesApiWebSearchFilters::from), - user_location: web_search_config - .user_location - .map(crate::client_common::tools::ResponsesApiWebSearchUserLocation::from), - search_context_size: web_search_config.search_context_size, - search_content_types: None, - } - ); - } - - #[test] - fn web_search_tool_type_text_and_image_sets_search_content_types() { - let config = test_config(); - let mut model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - model_info.web_search_tool_type = WebSearchToolType::TextAndImage; - let features = Features::with_defaults(); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - - let tool = find_tool(&tools, "web_search"); - assert_eq!( - tool.spec, - ToolSpec::WebSearch { - external_web_access: Some(true), - filters: None, - user_location: None, - search_context_size: None, - search_content_types: Some( - WEB_SEARCH_CONTENT_TYPES - .into_iter() - .map(str::to_string) - .collect() - ), - } - ); - } - - #[test] - fn mcp_resource_tools_are_hidden_without_mcp_servers() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - - assert!( - !tools.iter().any(|tool| matches!( - tool.spec.name(), - "list_mcp_resources" | "list_mcp_resource_templates" | "read_mcp_resource" - )), - "MCP resource tools should be omitted when no MCP servers are configured" - ); - } - - #[test] - fn mcp_resource_tools_are_included_when_mcp_servers_are_present() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); - - assert_contains_tool_names( - &tools, - &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", - ], - ); - } - - #[test] - fn test_build_specs_gpt5_codex_default() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5-codex", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "web_search", - "view_image", - ], - ); - } - - #[test] - fn test_build_specs_gpt51_codex_default() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.1-codex", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "web_search", - "view_image", - ], - ); - } - - #[test] - fn test_build_specs_gpt5_codex_unified_exec_web_search() { - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - assert_model_tools( - "gpt-5-codex", - &features, - Some(WebSearchMode::Live), - &[ - "exec_command", - "write_stdin", - "update_plan", - "request_user_input", - "apply_patch", - "web_search", - "view_image", - ], - ); - } - - #[test] - fn test_build_specs_gpt51_codex_unified_exec_web_search() { - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - assert_model_tools( - "gpt-5.1-codex", - &features, - Some(WebSearchMode::Live), - &[ - "exec_command", - "write_stdin", - "update_plan", - "request_user_input", - "apply_patch", - "web_search", - "view_image", - ], - ); - } - - #[test] - fn test_gpt_5_1_codex_max_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.1-codex-max", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "web_search", - "view_image", - ], - ); - } - - #[test] - fn test_codex_5_1_mini_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.1-codex-mini", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "web_search", - "view_image", - ], - ); - } - - #[test] - fn test_gpt_5_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5", - &features, - Some(WebSearchMode::Cached), - "shell", - &[ - "update_plan", - "request_user_input", - "web_search", - "view_image", - ], - ); - } - - #[test] - fn test_gpt_5_1_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.1", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "web_search", - "view_image", - ], - ); - } - - #[test] - fn test_gpt_5_1_codex_max_unified_exec_web_search() { - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - assert_model_tools( - "gpt-5.1-codex-max", - &features, - Some(WebSearchMode::Live), - &[ - "exec_command", - "write_stdin", - "update_plan", - "request_user_input", - "apply_patch", - "web_search", - "view_image", - ], - ); - } - - #[test] - fn test_build_specs_default_shell_present() { - let config = test_config(); - let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); - - // Only check the shell variant and a couple of core tools. - let mut subset = vec!["exec_command", "write_stdin", "update_plan"]; - if let Some(shell_tool) = shell_tool_name(&tools_config) { - subset.push(shell_tool); - } - assert_contains_tool_names(&tools, &subset); - } - - #[test] - fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { - let config = test_config(); - let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - features.enable(Feature::ShellZshFork); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - }); - - assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand); - assert_eq!( - tools_config.shell_command_backend, - ShellCommandBackendConfig::ZshFork - ); - assert_eq!( - tools_config.unified_exec_backend, - UnifiedExecBackendConfig::ZshFork - ); - } - - #[test] - #[ignore] - fn test_parallel_support_flags() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - - assert!(find_tool(&tools, "exec_command").supports_parallel_tool_calls); - assert!(!find_tool(&tools, "write_stdin").supports_parallel_tool_calls); - assert!(find_tool(&tools, "grep_files").supports_parallel_tool_calls); - assert!(find_tool(&tools, "list_dir").supports_parallel_tool_calls); - assert!(find_tool(&tools, "read_file").supports_parallel_tool_calls); - } - - #[test] - fn test_test_model_info_includes_sync_tool() { - let _config = test_config(); - let mut model_info = model_info_from_models_json("gpt-5-codex"); - model_info.experimental_supported_tools = vec![ - "test_sync_tool".to_string(), - "read_file".to_string(), - "grep_files".to_string(), - "list_dir".to_string(), - ]; - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - - assert!( - tools - .iter() - .any(|tool| tool_name(&tool.spec) == "test_sync_tool") - ); - assert!( - tools - .iter() - .any(|tool| tool_name(&tool.spec) == "read_file") - ); - assert!( - tools - .iter() - .any(|tool| tool_name(&tool.spec) == "grep_files") - ); - assert!(tools.iter().any(|tool| tool_name(&tool.spec) == "list_dir")); - } - - #[test] - fn test_build_specs_mcp_tools_converted() { - let config = test_config(); - let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - "test_server/do_something_cool".to_string(), - mcp_tool( - "do_something_cool", - "Do something cool", - serde_json::json!({ - "type": "object", - "properties": { - "string_argument": { "type": "string" }, - "number_argument": { "type": "number" }, - "object_argument": { - "type": "object", - "properties": { - "string_property": { "type": "string" }, - "number_property": { "type": "number" }, - }, - "required": ["string_property", "number_property"], - "additionalProperties": false, - }, - }, - }), - ), - )])), - None, - &[], - ) - .build(); - - let tool = find_tool(&tools, "test_server/do_something_cool"); - assert_eq!( - &tool.spec, - &ToolSpec::Function(ResponsesApiTool { - name: "test_server/do_something_cool".to_string(), - parameters: JsonSchema::Object { - properties: BTreeMap::from([ - ( - "string_argument".to_string(), - JsonSchema::String { description: None } - ), - ( - "number_argument".to_string(), - JsonSchema::Number { description: None } - ), - ( - "object_argument".to_string(), - JsonSchema::Object { - properties: BTreeMap::from([ - ( - "string_property".to_string(), - JsonSchema::String { description: None } - ), - ( - "number_property".to_string(), - JsonSchema::Number { description: None } - ), - ]), - required: Some(vec![ - "string_property".to_string(), - "number_property".to_string(), - ]), - additional_properties: Some(false.into()), - }, - ), - ]), - required: None, - additional_properties: None, - }, - description: "Do something cool".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - }) - ); - } - - #[test] - fn test_build_specs_mcp_tools_sorted_by_name() { - let config = test_config(); - let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - - // Intentionally construct a map with keys that would sort alphabetically. - let tools_map: HashMap = HashMap::from([ - ( - "test_server/do".to_string(), - mcp_tool("a", "a", serde_json::json!({"type": "object"})), - ), - ( - "test_server/something".to_string(), - mcp_tool("b", "b", serde_json::json!({"type": "object"})), - ), - ( - "test_server/cool".to_string(), - mcp_tool("c", "c", serde_json::json!({"type": "object"})), - ), - ]); - - let (tools, _) = build_specs(&tools_config, Some(tools_map), None, &[]).build(); - - // Only assert that the MCP tools themselves are sorted by fully-qualified name. - let mcp_names: Vec<_> = tools - .iter() - .map(|t| tool_name(&t.spec).to_string()) - .filter(|n| n.starts_with("test_server/")) - .collect(); - let expected = vec![ - "test_server/cool".to_string(), - "test_server/do".to_string(), - "test_server/something".to_string(), - ]; - assert_eq!(mcp_names, expected); - } - - #[test] - fn search_tool_description_includes_only_codex_apps_connector_names() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([ - ( - "mcp__codex_apps__calendar_create_event".to_string(), - mcp_tool( - "calendar_create_event", - "Create calendar event", - serde_json::json!({"type": "object"}), - ), - ), - ( - "mcp__rmcp__echo".to_string(), - mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), - ), - ])), - Some(HashMap::from([ - ( - "mcp__codex_apps__calendar_create_event".to_string(), - ToolInfo { - server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool_name: "calendar_create_event".to_string(), - tool: mcp_tool( - "calendar_create_event", - "Create calendar event", - serde_json::json!({"type": "object"}), - ), - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - plugin_display_names: Vec::new(), - }, - ), - ( - "mcp__rmcp__echo".to_string(), - ToolInfo { - server_name: "rmcp".to_string(), - tool_name: "echo".to_string(), - tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), - connector_id: None, - connector_name: None, - plugin_display_names: Vec::new(), - }, - ), - ])), - &[], - ) - .build(); - - let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else { - panic!("expected function tool"); - }; - assert!(description.contains("Calendar")); - assert!(!description.contains("mcp__rmcp__echo")); - } - - #[test] - fn search_tool_requires_apps_feature_flag_only() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let app_tools = Some(HashMap::from([( - "mcp__codex_apps__calendar_create_event".to_string(), - ToolInfo { - server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool_name: "calendar_create_event".to_string(), - tool: mcp_tool( - "calendar_create_event", - "Create calendar event", - serde_json::json!({"type": "object"}), - ), - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - plugin_display_names: Vec::new(), - }, - )])); - - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); - assert_lacks_tool_name(&tools, SEARCH_TOOL_BM25_TOOL_NAME); - - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs(&tools_config, None, app_tools, &[]).build(); - assert_contains_tool_names(&tools, &[SEARCH_TOOL_BM25_TOOL_NAME]); - } - - #[test] - fn search_tool_description_handles_no_enabled_apps() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - - let (tools, _) = build_specs(&tools_config, None, Some(HashMap::new()), &[]).build(); - let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else { - panic!("expected function tool"); - }; - - assert!(description.contains("(None currently enabled)")); - assert!(description.contains("available apps.")); - assert!(!description.contains("{{app_names}}")); - } - - #[test] - fn test_mcp_tool_property_missing_type_defaults_to_string() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - "dash/search".to_string(), - mcp_tool( - "search", - "Search docs", - serde_json::json!({ - "type": "object", - "properties": { - "query": {"description": "search query"} - } - }), - ), - )])), - None, - &[], - ) - .build(); - - let tool = find_tool(&tools, "dash/search"); - assert_eq!( - tool.spec, - ToolSpec::Function(ResponsesApiTool { - name: "dash/search".to_string(), - parameters: JsonSchema::Object { - properties: BTreeMap::from([( - "query".to_string(), - JsonSchema::String { - description: Some("search query".to_string()) - } - )]), - required: None, - additional_properties: None, - }, - description: "Search docs".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - }) - ); - } - - #[test] - fn test_mcp_tool_integer_normalized_to_number() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - "dash/paginate".to_string(), - mcp_tool( - "paginate", - "Pagination", - serde_json::json!({ - "type": "object", - "properties": {"page": {"type": "integer"}} - }), - ), - )])), - None, - &[], - ) - .build(); - - let tool = find_tool(&tools, "dash/paginate"); - assert_eq!( - tool.spec, - ToolSpec::Function(ResponsesApiTool { - name: "dash/paginate".to_string(), - parameters: JsonSchema::Object { - properties: BTreeMap::from([( - "page".to_string(), - JsonSchema::Number { description: None } - )]), - required: None, - additional_properties: None, - }, - description: "Pagination".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - }) - ); - } - - #[test] - fn test_mcp_tool_array_without_items_gets_default_string_items() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - features.enable(Feature::ApplyPatchFreeform); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - "dash/tags".to_string(), - mcp_tool( - "tags", - "Tags", - serde_json::json!({ - "type": "object", - "properties": {"tags": {"type": "array"}} - }), - ), - )])), - None, - &[], - ) - .build(); - - let tool = find_tool(&tools, "dash/tags"); - assert_eq!( - tool.spec, - ToolSpec::Function(ResponsesApiTool { - name: "dash/tags".to_string(), - parameters: JsonSchema::Object { - properties: BTreeMap::from([( - "tags".to_string(), - JsonSchema::Array { - items: Box::new(JsonSchema::String { description: None }), - description: None - } - )]), - required: None, - additional_properties: None, - }, - description: "Tags".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - }) - ); - } - - #[test] - fn test_mcp_tool_anyof_defaults_to_string() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - "dash/value".to_string(), - mcp_tool( - "value", - "AnyOf Value", - serde_json::json!({ - "type": "object", - "properties": { - "value": {"anyOf": [{"type": "string"}, {"type": "number"}]} - } - }), - ), - )])), - None, - &[], - ) - .build(); - - let tool = find_tool(&tools, "dash/value"); - assert_eq!( - tool.spec, - ToolSpec::Function(ResponsesApiTool { - name: "dash/value".to_string(), - parameters: JsonSchema::Object { - properties: BTreeMap::from([( - "value".to_string(), - JsonSchema::String { description: None } - )]), - required: None, - additional_properties: None, - }, - description: "AnyOf Value".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - }) - ); - } - - #[test] - fn test_shell_tool() { - let tool = super::create_shell_tool(false); - let ToolSpec::Function(ResponsesApiTool { - description, name, .. - }) = &tool - else { - panic!("expected function tool"); - }; - assert_eq!(name, "shell"); - - let expected = if cfg!(windows) { - r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. - -Examples of valid command strings: - -- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] -- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] -- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] -- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"] -- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] -- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"# - } else { - r#"Runs a shell command and returns its output. -- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. -- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# - }.to_string(); - assert_eq!(description, &expected); - } - - #[test] - fn shell_tool_with_request_permission_includes_additional_permissions() { - let tool = super::create_shell_tool(true); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { - panic!("expected function tool"); - }; - let JsonSchema::Object { properties, .. } = parameters else { - panic!("expected object parameters"); - }; - - assert!(properties.contains_key("additional_permissions")); - - let Some(JsonSchema::String { - description: Some(description), - }) = properties.get("sandbox_permissions") - else { - panic!("expected sandbox_permissions description"); - }; - assert!(description.contains("with_additional_permissions")); - assert!(description.contains("macOS permissions")); - - let Some(JsonSchema::Object { - properties: additional_properties, - .. - }) = properties.get("additional_permissions") - else { - panic!("expected additional_permissions schema"); - }; - assert!(additional_properties.contains_key("network")); - assert!(additional_properties.contains_key("file_system")); - assert!(additional_properties.contains_key("macos")); - } - - #[test] - fn request_permissions_tool_includes_full_permission_schema() { - let tool = super::create_request_permissions_tool(); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { - panic!("expected function tool"); - }; - let JsonSchema::Object { properties, .. } = parameters else { - panic!("expected object parameters"); - }; - let Some(JsonSchema::Object { - properties: permission_properties, - additional_properties, - .. - }) = properties.get("permissions") - else { - panic!("expected permissions object"); - }; - - assert_eq!(additional_properties, &Some(false.into())); - assert!(permission_properties.contains_key("network")); - assert!(permission_properties.contains_key("file_system")); - assert!(permission_properties.contains_key("macos")); - - let Some(JsonSchema::Object { - properties: network_properties, - additional_properties, - .. - }) = permission_properties.get("network") - else { - panic!("expected network object"); - }; - assert_eq!(additional_properties, &Some(false.into())); - assert!(network_properties.contains_key("enabled")); - - let Some(JsonSchema::Object { - properties: file_system_properties, - additional_properties, - .. - }) = permission_properties.get("file_system") - else { - panic!("expected file_system object"); - }; - assert_eq!(additional_properties, &Some(false.into())); - assert!(file_system_properties.contains_key("read")); - assert!(file_system_properties.contains_key("write")); - - let Some(JsonSchema::Object { - properties: macos_properties, - additional_properties, - .. - }) = permission_properties.get("macos") - else { - panic!("expected macos object"); - }; - assert_eq!(additional_properties, &Some(false.into())); - assert!(macos_properties.contains_key("preferences")); - assert!(macos_properties.contains_key("automations")); - assert!(macos_properties.contains_key("accessibility")); - assert!(macos_properties.contains_key("calendar")); - } - - #[test] - fn test_shell_command_tool() { - let tool = super::create_shell_command_tool(true, false); - let ToolSpec::Function(ResponsesApiTool { - description, name, .. - }) = &tool - else { - panic!("expected function tool"); - }; - assert_eq!(name, "shell_command"); - - let expected = if cfg!(windows) { - r#"Runs a Powershell command (Windows) and returns its output. - -Examples of valid command strings: - -- ls -a (show hidden): "Get-ChildItem -Force" -- recursive find by name: "Get-ChildItem -Recurse -Filter *.py" -- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive" -- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }" -- setting an env var: "$env:FOO='bar'; echo $env:FOO" -- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -"#.to_string() - } else { - r#"Runs a shell command and returns its output. -- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#.to_string() - }; - assert_eq!(description, &expected); - } - - #[test] - fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - "test_server/do_something_cool".to_string(), - mcp_tool( - "do_something_cool", - "Do something cool", - serde_json::json!({ - "type": "object", - "properties": { - "string_argument": {"type": "string"}, - "number_argument": {"type": "number"}, - "object_argument": { - "type": "object", - "properties": { - "string_property": {"type": "string"}, - "number_property": {"type": "number"} - }, - "required": ["string_property", "number_property"], - "additionalProperties": { - "type": "object", - "properties": { - "addtl_prop": {"type": "string"} - }, - "required": ["addtl_prop"], - "additionalProperties": false - } - } - } - }), - ), - )])), - None, - &[], - ) - .build(); - - let tool = find_tool(&tools, "test_server/do_something_cool"); - assert_eq!( - tool.spec, - ToolSpec::Function(ResponsesApiTool { - name: "test_server/do_something_cool".to_string(), - parameters: JsonSchema::Object { - properties: BTreeMap::from([ - ( - "string_argument".to_string(), - JsonSchema::String { description: None } - ), - ( - "number_argument".to_string(), - JsonSchema::Number { description: None } - ), - ( - "object_argument".to_string(), - JsonSchema::Object { - properties: BTreeMap::from([ - ( - "string_property".to_string(), - JsonSchema::String { description: None } - ), - ( - "number_property".to_string(), - JsonSchema::Number { description: None } - ), - ]), - required: Some(vec![ - "string_property".to_string(), - "number_property".to_string(), - ]), - additional_properties: Some( - JsonSchema::Object { - properties: BTreeMap::from([( - "addtl_prop".to_string(), - JsonSchema::String { description: None } - ),]), - required: Some(vec!["addtl_prop".to_string(),]), - additional_properties: Some(false.into()), - } - .into() - ), - }, - ), - ]), - required: None, - additional_properties: None, - }, - description: "Do something cool".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - }) - ); - } - - #[test] - fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - let ToolSpec::Function(ResponsesApiTool { description, .. }) = - &find_tool(&tools, "view_image").spec - else { - panic!("expected function tool"); - }; - - assert_eq!( - description, - "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nCode mode declaration:\n```ts\nimport { view_image } from \"tools.js\";\ndeclare function view_image(args: {\n path: string;\n}): Promise;\n```" - ); - } - - #[test] - fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - }); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - "mcp__sample__echo".to_string(), - mcp_tool( - "echo", - "Echo text", - serde_json::json!({ - "type": "object", - "properties": { - "message": {"type": "string"} - }, - "required": ["message"], - "additionalProperties": false - }), - ), - )])), - None, - &[], - ) - .build(); - - let ToolSpec::Function(ResponsesApiTool { description, .. }) = - &find_tool(&tools, "mcp__sample__echo").spec - else { - panic!("expected function tool"); - }; - - assert_eq!( - description, - "Echo text\n\nCode mode declaration:\n```ts\nimport { echo } from \"tools/mcp/sample.js\";\ndeclare function echo(args: {\n message: string;\n}): Promise<{\n _meta?: unknown;\n content: Array;\n isError?: boolean;\n structuredContent?: unknown;\n}>;\n```" - ); - } - - #[test] - fn chat_tools_include_top_level_name() { - let properties = - BTreeMap::from([("foo".to_string(), JsonSchema::String { description: None })]); - let tools = vec![ToolSpec::Function(ResponsesApiTool { - name: "demo".to_string(), - description: "A demo tool".to_string(), - strict: false, - parameters: JsonSchema::Object { - properties, - required: None, - additional_properties: None, - }, - output_schema: None, - })]; - - let responses_json = create_tools_json_for_responses_api(&tools).unwrap(); - assert_eq!( - responses_json, - vec![json!({ - "type": "function", - "name": "demo", - "description": "A demo tool", - "strict": false, - "parameters": { - "type": "object", - "properties": { - "foo": { "type": "string" } - }, - }, - })] - ); - } -} +#[path = "spec_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs new file mode 100644 index 0000000000..a1b7857962 --- /dev/null +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -0,0 +1,2397 @@ +use crate::client_common::tools::FreeformTool; +use crate::config::test_config; +use crate::models_manager::manager::ModelsManager; +use crate::models_manager::model_info::with_config_overrides; +use crate::tools::registry::ConfiguredToolSpec; +use codex_app_server_protocol::AppInfo; +use codex_protocol::openai_models::InputModality; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelsResponse; +use pretty_assertions::assert_eq; + +use super::*; + +fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { + rmcp::model::Tool { + name: name.to_string().into(), + title: None, + description: Some(description.to_string().into()), + input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + } +} + +fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool { + let slug = name.replace(' ', "-").to_lowercase(); + DiscoverableTool::Connector(Box::new(AppInfo { + id: id.to_string(), + name: name.to_string(), + description: Some(description.to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some(format!("https://chatgpt.com/apps/{slug}/{id}")), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + })) +} + +#[test] +fn mcp_tool_to_openai_tool_inserts_empty_properties() { + let mut schema = rmcp::model::JsonObject::new(); + schema.insert("type".to_string(), serde_json::json!("object")); + + let tool = rmcp::model::Tool { + name: "no_props".to_string().into(), + title: None, + description: Some("No properties".to_string().into()), + input_schema: std::sync::Arc::new(schema), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }; + + let openai_tool = + mcp_tool_to_openai_tool("server/no_props".to_string(), tool).expect("convert tool"); + let parameters = serde_json::to_value(openai_tool.parameters).expect("serialize schema"); + + assert_eq!(parameters.get("properties"), Some(&serde_json::json!({}))); +} + +#[test] +fn mcp_tool_to_openai_tool_preserves_top_level_output_schema() { + let mut input_schema = rmcp::model::JsonObject::new(); + input_schema.insert("type".to_string(), serde_json::json!("object")); + + let mut output_schema = rmcp::model::JsonObject::new(); + output_schema.insert( + "properties".to_string(), + serde_json::json!({ + "result": { + "properties": { + "nested": {} + } + } + }), + ); + output_schema.insert("required".to_string(), serde_json::json!(["result"])); + + let tool = rmcp::model::Tool { + name: "with_output".to_string().into(), + title: None, + description: Some("Has output schema".to_string().into()), + input_schema: std::sync::Arc::new(input_schema), + output_schema: Some(std::sync::Arc::new(output_schema)), + annotations: None, + execution: None, + icons: None, + meta: None, + }; + + let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_output".to_string(), tool) + .expect("convert tool"); + + assert_eq!( + openai_tool.output_schema, + Some(serde_json::json!({ + "type": "object", + "properties": { + "content": { + "type": "array", + "items": {} + }, + "structuredContent": { + "properties": { + "result": { + "properties": { + "nested": {} + } + } + }, + "required": ["result"] + }, + "isError": { + "type": "boolean" + }, + "_meta": {} + }, + "required": ["content"], + "additionalProperties": false + })) + ); +} + +#[test] +fn mcp_tool_to_openai_tool_preserves_output_schema_without_inferred_type() { + let mut input_schema = rmcp::model::JsonObject::new(); + input_schema.insert("type".to_string(), serde_json::json!("object")); + + let mut output_schema = rmcp::model::JsonObject::new(); + output_schema.insert("enum".to_string(), serde_json::json!(["ok", "error"])); + + let tool = rmcp::model::Tool { + name: "with_enum_output".to_string().into(), + title: None, + description: Some("Has enum output schema".to_string().into()), + input_schema: std::sync::Arc::new(input_schema), + output_schema: Some(std::sync::Arc::new(output_schema)), + annotations: None, + execution: None, + icons: None, + meta: None, + }; + + let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_enum_output".to_string(), tool) + .expect("convert tool"); + + assert_eq!( + openai_tool.output_schema, + Some(serde_json::json!({ + "type": "object", + "properties": { + "content": { + "type": "array", + "items": {} + }, + "structuredContent": { + "enum": ["ok", "error"] + }, + "isError": { + "type": "boolean" + }, + "_meta": {} + }, + "required": ["content"], + "additionalProperties": false + })) + ); +} + +#[test] +fn search_tool_deferred_tools_always_set_defer_loading_true() { + let tool = mcp_tool( + "lookup_order", + "Look up an order", + serde_json::json!({ + "type": "object", + "properties": { + "order_id": {"type": "string"} + }, + "required": ["order_id"], + "additionalProperties": false, + }), + ); + + let openai_tool = + mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool) + .expect("convert deferred tool"); + + assert_eq!(openai_tool.defer_loading, Some(true)); +} + +#[test] +fn deferred_responses_api_tool_serializes_with_defer_loading() { + let tool = mcp_tool( + "lookup_order", + "Look up an order", + serde_json::json!({ + "type": "object", + "properties": { + "order_id": {"type": "string"} + }, + "required": ["order_id"], + "additionalProperties": false, + }), + ); + + let serialized = serde_json::to_value(ToolSpec::Function( + mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool) + .expect("convert deferred tool"), + )) + .expect("serialize deferred tool"); + + assert_eq!( + serialized, + serde_json::json!({ + "type": "function", + "name": "mcp__codex_apps__lookup_order", + "description": "Look up an order", + "strict": false, + "defer_loading": true, + "parameters": { + "type": "object", + "properties": { + "order_id": {"type": "string"} + }, + "required": ["order_id"], + "additionalProperties": false, + } + }) + ); +} + +fn tool_name(tool: &ToolSpec) -> &str { + match tool { + ToolSpec::Function(ResponsesApiTool { name, .. }) => name, + ToolSpec::ToolSearch { .. } => "tool_search", + ToolSpec::LocalShell {} => "local_shell", + ToolSpec::ImageGeneration { .. } => "image_generation", + ToolSpec::WebSearch { .. } => "web_search", + ToolSpec::Freeform(FreeformTool { name, .. }) => name, + } +} + +// Avoid order-based assertions; compare via set containment instead. +fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[&str]) { + use std::collections::HashSet; + let mut names = HashSet::new(); + let mut duplicates = Vec::new(); + for name in tools.iter().map(|t| tool_name(&t.spec)) { + if !names.insert(name) { + duplicates.push(name); + } + } + assert!( + duplicates.is_empty(), + "duplicate tool entries detected: {duplicates:?}" + ); + for expected in expected_subset { + assert!( + names.contains(expected), + "expected tool {expected} to be present; had: {names:?}" + ); + } +} + +fn assert_lacks_tool_name(tools: &[ConfiguredToolSpec], expected_absent: &str) { + let names = tools + .iter() + .map(|tool| tool_name(&tool.spec)) + .collect::>(); + assert!( + !names.contains(&expected_absent), + "expected tool {expected_absent} to be absent; had: {names:?}" + ); +} + +fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> { + match config.shell_type { + ConfigShellToolType::Default => Some("shell"), + ConfigShellToolType::Local => Some("local_shell"), + ConfigShellToolType::UnifiedExec => None, + ConfigShellToolType::Disabled => None, + ConfigShellToolType::ShellCommand => Some("shell_command"), + } +} + +fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a ConfiguredToolSpec { + tools + .iter() + .find(|tool| tool_name(&tool.spec) == expected_name) + .unwrap_or_else(|| panic!("expected tool {expected_name}")) +} + +fn strip_descriptions_schema(schema: &mut JsonSchema) { + match schema { + JsonSchema::Boolean { description } + | JsonSchema::String { description } + | JsonSchema::Number { description } => { + *description = None; + } + JsonSchema::Array { items, description } => { + strip_descriptions_schema(items); + *description = None; + } + JsonSchema::Object { + properties, + required: _, + additional_properties, + } => { + for v in properties.values_mut() { + strip_descriptions_schema(v); + } + if let Some(AdditionalProperties::Schema(s)) = additional_properties { + strip_descriptions_schema(s); + } + } + } +} + +fn strip_descriptions_tool(spec: &mut ToolSpec) { + match spec { + ToolSpec::ToolSearch { parameters, .. } => strip_descriptions_schema(parameters), + ToolSpec::Function(ResponsesApiTool { parameters, .. }) => { + strip_descriptions_schema(parameters); + } + ToolSpec::Freeform(_) + | ToolSpec::LocalShell {} + | ToolSpec::ImageGeneration { .. } + | ToolSpec::WebSearch { .. } => {} + } +} + +fn model_info_from_models_json(slug: &str) -> ModelInfo { + let config = test_config(); + let response: ModelsResponse = + serde_json::from_str(include_str!("../../models.json")).expect("valid models.json"); + let model = response + .models + .into_iter() + .find(|candidate| candidate.slug == slug) + .unwrap_or_else(|| panic!("model slug {slug} is missing from models.json")); + with_config_overrides(model, &config) +} + +#[test] +fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { + let model_info = model_info_from_models_json("gpt-5-codex"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&config, None, None, &[]).build(); + + // Build actual map name -> spec + use std::collections::BTreeMap; + use std::collections::HashSet; + let mut actual: BTreeMap = BTreeMap::from([]); + let mut duplicate_names = Vec::new(); + for t in &tools { + let name = tool_name(&t.spec).to_string(); + if actual.insert(name.clone(), t.spec.clone()).is_some() { + duplicate_names.push(name); + } + } + assert!( + duplicate_names.is_empty(), + "duplicate tool entries detected: {duplicate_names:?}" + ); + + // Build expected from the same helpers used by the builder. + let mut expected: BTreeMap = BTreeMap::from([]); + for spec in [ + create_exec_command_tool(true, false), + create_write_stdin_tool(), + PLAN_TOOL.clone(), + create_request_user_input_tool(CollaborationModesConfig::default()), + create_apply_patch_freeform_tool(), + ToolSpec::WebSearch { + external_web_access: Some(true), + filters: None, + user_location: None, + search_context_size: None, + search_content_types: None, + }, + create_view_image_tool(config.can_request_original_image_detail), + ] { + expected.insert(tool_name(&spec).to_string(), spec); + } + + if config.request_permission_enabled { + let spec = create_request_permissions_tool(); + expected.insert(tool_name(&spec).to_string(), spec); + } + + // Exact name set match — this is the only test allowed to fail when tools change. + let actual_names: HashSet<_> = actual.keys().cloned().collect(); + let expected_names: HashSet<_> = expected.keys().cloned().collect(); + assert_eq!(actual_names, expected_names, "tool name set mismatch"); + + // Compare specs ignoring human-readable descriptions. + for name in expected.keys() { + let mut a = actual.get(name).expect("present").clone(); + let mut e = expected.get(name).expect("present").clone(); + strip_descriptions_tool(&mut a); + strip_descriptions_tool(&mut e); + assert_eq!(a, e, "spec mismatch for {name}"); + } +} + +#[test] +fn test_build_specs_collab_tools_enabled() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Collab); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_contains_tool_names( + &tools, + &["spawn_agent", "send_input", "wait", "close_agent"], + ); + assert_lacks_tool_name(&tools, "spawn_agents_on_csv"); +} + +#[test] +fn test_build_specs_spawn_csv_enables_agent_jobs_and_collab_tools() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::SpawnCsv); + features.normalize_dependencies(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_contains_tool_names( + &tools, + &[ + "spawn_agent", + "send_input", + "wait", + "close_agent", + "spawn_agents_on_csv", + ], + ); +} + +#[test] +fn view_image_tool_omits_detail_without_original_detail_feature() { + let config = test_config(); + let mut model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + model_info.supports_image_detail_original = true; + let features = Features::with_defaults(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &view_image.spec else { + panic!("view_image should be a function tool"); + }; + let JsonSchema::Object { properties, .. } = parameters else { + panic!("view_image should use an object schema"); + }; + assert!(!properties.contains_key("detail")); +} + +#[test] +fn view_image_tool_includes_detail_with_original_detail_feature() { + let config = test_config(); + let mut model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + model_info.supports_image_detail_original = true; + let mut features = Features::with_defaults(); + features.enable(Feature::ImageDetailOriginal); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &view_image.spec else { + panic!("view_image should be a function tool"); + }; + let JsonSchema::Object { properties, .. } = parameters else { + panic!("view_image should use an object schema"); + }; + assert!(properties.contains_key("detail")); + let Some(JsonSchema::String { + description: Some(description), + }) = properties.get("detail") + else { + panic!("view_image detail should include a description"); + }; + assert!(description.contains("only supported value is `original`")); + assert!(description.contains("omit this field for default resized behavior")); +} + +#[test] +fn test_build_specs_artifact_tool_enabled() { + let mut config = test_config(); + let runtime_root = tempfile::TempDir::new().expect("create temp codex home"); + config.codex_home = runtime_root.path().to_path_buf(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Artifact); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_contains_tool_names(&tools, &["artifacts"]); +} + +#[test] +fn test_build_specs_agent_job_worker_tools_enabled() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::SpawnCsv); + features.normalize_dependencies(); + features.enable(Feature::Sqlite); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::SubAgent(SubAgentSource::Other( + "agent_job:test".to_string(), + )), + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_contains_tool_names( + &tools, + &[ + "spawn_agent", + "send_input", + "resume_agent", + "wait", + "close_agent", + "spawn_agents_on_csv", + "report_agent_job_result", + ], + ); + assert_lacks_tool_name(&tools, "request_user_input"); +} + +#[test] +fn request_user_input_description_reflects_default_mode_feature_flag() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let request_user_input_tool = find_tool(&tools, "request_user_input"); + assert_eq!( + request_user_input_tool.spec, + create_request_user_input_tool(CollaborationModesConfig::default()) + ); + + features.enable(Feature::DefaultModeRequestUserInput); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let request_user_input_tool = find_tool(&tools, "request_user_input"); + assert_eq!( + request_user_input_tool.spec, + create_request_user_input_tool(CollaborationModesConfig { + default_mode_request_user_input: true, + }) + ); +} + +#[test] +fn request_permissions_requires_feature_flag() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let features = Features::with_defaults(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_lacks_tool_name(&tools, "request_permissions"); + + let mut features = Features::with_defaults(); + features.enable(Feature::RequestPermissionsTool); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let request_permissions_tool = find_tool(&tools, "request_permissions"); + assert_eq!( + request_permissions_tool.spec, + create_request_permissions_tool() + ); +} + +#[test] +fn request_permissions_tool_is_independent_from_additional_permissions() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::RequestPermissions); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + assert_lacks_tool_name(&tools, "request_permissions"); +} + +#[test] +fn get_memory_requires_feature_flag() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.disable(Feature::MemoryTool); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert!( + !tools.iter().any(|t| t.spec.name() == "get_memory"), + "get_memory should be disabled when memory_tool feature is off" + ); +} + +#[test] +fn js_repl_requires_feature_flag() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let features = Features::with_defaults(); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + assert!( + !tools.iter().any(|tool| tool.spec.name() == "js_repl"), + "js_repl should be disabled when the feature is off" + ); + assert!( + !tools.iter().any(|tool| tool.spec.name() == "js_repl_reset"), + "js_repl_reset should be disabled when the feature is off" + ); +} + +#[test] +fn js_repl_enabled_adds_tools() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::JsRepl); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]); +} + +#[test] +fn image_generation_tools_require_feature_and_supported_model() { + let config = test_config(); + let mut supported_model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5.2", &config); + supported_model_info.slug = "custom/gpt-5.2-variant".to_string(); + let mut unsupported_model_info = supported_model_info.clone(); + unsupported_model_info.input_modalities = vec![InputModality::Text]; + let default_features = Features::with_defaults(); + let mut image_generation_features = default_features.clone(); + image_generation_features.enable(Feature::ImageGeneration); + + let available_models = Vec::new(); + let default_tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &supported_model_info, + available_models: &available_models, + features: &default_features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (default_tools, _) = build_specs(&default_tools_config, None, None, &[]).build(); + assert!( + !default_tools + .iter() + .any(|tool| tool.spec.name() == "image_generation"), + "image_generation should be disabled by default" + ); + + let supported_tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &supported_model_info, + available_models: &available_models, + features: &image_generation_features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (supported_tools, _) = build_specs(&supported_tools_config, None, None, &[]).build(); + assert_contains_tool_names(&supported_tools, &["image_generation"]); + let image_generation_tool = find_tool(&supported_tools, "image_generation"); + assert_eq!( + serde_json::to_value(&image_generation_tool.spec).expect("serialize image tool"), + serde_json::json!({ + "type": "image_generation", + "output_format": "png" + }) + ); + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &unsupported_model_info, + available_models: &available_models, + features: &image_generation_features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert!( + !tools + .iter() + .any(|tool| tool.spec.name() == "image_generation"), + "image_generation should be disabled for unsupported models" + ); +} + +#[test] +fn js_repl_freeform_grammar_blocks_common_non_js_prefixes() { + let ToolSpec::Freeform(FreeformTool { format, .. }) = create_js_repl_tool() else { + panic!("js_repl should use a freeform tool spec"); + }; + + assert_eq!(format.syntax, "lark"); + assert!(format.definition.contains("PRAGMA_LINE")); + assert!(format.definition.contains("`[^`]")); + assert!(format.definition.contains("``[^`]")); + assert!(format.definition.contains("PLAIN_JS_SOURCE")); + assert!(format.definition.contains("codex-js-repl:")); + assert!(!format.definition.contains("(?!")); +} + +fn assert_model_tools( + model_slug: &str, + features: &Features, + web_search_mode: Option, + expected_tools: &[&str], +) { + let _config = test_config(); + let model_info = model_info_from_models_json(model_slug); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features, + web_search_mode, + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let tool_names = tools.iter().map(|t| t.spec.name()).collect::>(); + assert_eq!(&tool_names, &expected_tools,); +} + +fn assert_default_model_tools( + model_slug: &str, + features: &Features, + web_search_mode: Option, + shell_tool: &'static str, + expected_tail: &[&str], +) { + let mut expected = if features.enabled(Feature::UnifiedExec) { + vec!["exec_command", "write_stdin"] + } else { + vec![shell_tool] + }; + expected.extend(expected_tail); + assert_model_tools(model_slug, features, web_search_mode, &expected); +} + +#[test] +fn web_search_mode_cached_sets_external_web_access_false() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let features = Features::with_defaults(); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + let tool = find_tool(&tools, "web_search"); + assert_eq!( + tool.spec, + ToolSpec::WebSearch { + external_web_access: Some(false), + filters: None, + user_location: None, + search_context_size: None, + search_content_types: None, + } + ); +} + +#[test] +fn web_search_mode_live_sets_external_web_access_true() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let features = Features::with_defaults(); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + let tool = find_tool(&tools, "web_search"); + assert_eq!( + tool.spec, + ToolSpec::WebSearch { + external_web_access: Some(true), + filters: None, + user_location: None, + search_context_size: None, + search_content_types: None, + } + ); +} + +#[test] +fn web_search_config_is_forwarded_to_tool_spec() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let features = Features::with_defaults(); + let web_search_config = WebSearchConfig { + filters: Some(codex_protocol::config_types::WebSearchFilters { + allowed_domains: Some(vec!["example.com".to_string()]), + }), + user_location: Some(codex_protocol::config_types::WebSearchUserLocation { + r#type: codex_protocol::config_types::WebSearchUserLocationType::Approximate, + country: Some("US".to_string()), + region: Some("California".to_string()), + city: Some("San Francisco".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + }), + search_context_size: Some(codex_protocol::config_types::WebSearchContextSize::High), + }; + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + }) + .with_web_search_config(Some(web_search_config.clone())); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + let tool = find_tool(&tools, "web_search"); + assert_eq!( + tool.spec, + ToolSpec::WebSearch { + external_web_access: Some(true), + filters: web_search_config + .filters + .map(crate::client_common::tools::ResponsesApiWebSearchFilters::from), + user_location: web_search_config + .user_location + .map(crate::client_common::tools::ResponsesApiWebSearchUserLocation::from), + search_context_size: web_search_config.search_context_size, + search_content_types: None, + } + ); +} + +#[test] +fn web_search_tool_type_text_and_image_sets_search_content_types() { + let config = test_config(); + let mut model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + model_info.web_search_tool_type = WebSearchToolType::TextAndImage; + let features = Features::with_defaults(); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + let tool = find_tool(&tools, "web_search"); + assert_eq!( + tool.spec, + ToolSpec::WebSearch { + external_web_access: Some(true), + filters: None, + user_location: None, + search_context_size: None, + search_content_types: Some( + WEB_SEARCH_CONTENT_TYPES + .into_iter() + .map(str::to_string) + .collect() + ), + } + ); +} + +#[test] +fn mcp_resource_tools_are_hidden_without_mcp_servers() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let features = Features::with_defaults(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + assert!( + !tools.iter().any(|tool| matches!( + tool.spec.name(), + "list_mcp_resources" | "list_mcp_resource_templates" | "read_mcp_resource" + )), + "MCP resource tools should be omitted when no MCP servers are configured" + ); +} + +#[test] +fn mcp_resource_tools_are_included_when_mcp_servers_are_present() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let features = Features::with_defaults(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); + + assert_contains_tool_names( + &tools, + &[ + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + ], + ); +} + +#[test] +fn test_build_specs_gpt5_codex_default() { + let features = Features::with_defaults(); + assert_default_model_tools( + "gpt-5-codex", + &features, + Some(WebSearchMode::Cached), + "shell_command", + &[ + "update_plan", + "request_user_input", + "apply_patch", + "web_search", + "view_image", + ], + ); +} + +#[test] +fn test_build_specs_gpt51_codex_default() { + let features = Features::with_defaults(); + assert_default_model_tools( + "gpt-5.1-codex", + &features, + Some(WebSearchMode::Cached), + "shell_command", + &[ + "update_plan", + "request_user_input", + "apply_patch", + "web_search", + "view_image", + ], + ); +} + +#[test] +fn test_build_specs_gpt5_codex_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + assert_model_tools( + "gpt-5-codex", + &features, + Some(WebSearchMode::Live), + &[ + "exec_command", + "write_stdin", + "update_plan", + "request_user_input", + "apply_patch", + "web_search", + "view_image", + ], + ); +} + +#[test] +fn test_build_specs_gpt51_codex_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + assert_model_tools( + "gpt-5.1-codex", + &features, + Some(WebSearchMode::Live), + &[ + "exec_command", + "write_stdin", + "update_plan", + "request_user_input", + "apply_patch", + "web_search", + "view_image", + ], + ); +} + +#[test] +fn test_gpt_5_1_codex_max_defaults() { + let features = Features::with_defaults(); + assert_default_model_tools( + "gpt-5.1-codex-max", + &features, + Some(WebSearchMode::Cached), + "shell_command", + &[ + "update_plan", + "request_user_input", + "apply_patch", + "web_search", + "view_image", + ], + ); +} + +#[test] +fn test_codex_5_1_mini_defaults() { + let features = Features::with_defaults(); + assert_default_model_tools( + "gpt-5.1-codex-mini", + &features, + Some(WebSearchMode::Cached), + "shell_command", + &[ + "update_plan", + "request_user_input", + "apply_patch", + "web_search", + "view_image", + ], + ); +} + +#[test] +fn test_gpt_5_defaults() { + let features = Features::with_defaults(); + assert_default_model_tools( + "gpt-5", + &features, + Some(WebSearchMode::Cached), + "shell", + &[ + "update_plan", + "request_user_input", + "web_search", + "view_image", + ], + ); +} + +#[test] +fn test_gpt_5_1_defaults() { + let features = Features::with_defaults(); + assert_default_model_tools( + "gpt-5.1", + &features, + Some(WebSearchMode::Cached), + "shell_command", + &[ + "update_plan", + "request_user_input", + "apply_patch", + "web_search", + "view_image", + ], + ); +} + +#[test] +fn test_gpt_5_1_codex_max_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + assert_model_tools( + "gpt-5.1-codex-max", + &features, + Some(WebSearchMode::Live), + &[ + "exec_command", + "write_stdin", + "update_plan", + "request_user_input", + "apply_patch", + "web_search", + "view_image", + ], + ); +} + +#[test] +fn test_build_specs_default_shell_present() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); + + // Only check the shell variant and a couple of core tools. + let mut subset = vec!["exec_command", "write_stdin", "update_plan"]; + if let Some(shell_tool) = shell_tool_name(&tools_config) { + subset.push(shell_tool); + } + assert_contains_tool_names(&tools, &subset); +} + +#[test] +fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::ShellZshFork); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + }); + + assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand); + assert_eq!( + tools_config.shell_command_backend, + ShellCommandBackendConfig::ZshFork + ); + assert_eq!( + tools_config.unified_exec_backend, + UnifiedExecBackendConfig::ZshFork + ); +} + +#[test] +#[ignore] +fn test_parallel_support_flags() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + assert!(find_tool(&tools, "exec_command").supports_parallel_tool_calls); + assert!(!find_tool(&tools, "write_stdin").supports_parallel_tool_calls); + assert!(find_tool(&tools, "grep_files").supports_parallel_tool_calls); + assert!(find_tool(&tools, "list_dir").supports_parallel_tool_calls); + assert!(find_tool(&tools, "read_file").supports_parallel_tool_calls); +} + +#[test] +fn test_test_model_info_includes_sync_tool() { + let _config = test_config(); + let mut model_info = model_info_from_models_json("gpt-5-codex"); + model_info.experimental_supported_tools = vec![ + "test_sync_tool".to_string(), + "read_file".to_string(), + "grep_files".to_string(), + "list_dir".to_string(), + ]; + let features = Features::with_defaults(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + assert!( + tools + .iter() + .any(|tool| tool_name(&tool.spec) == "test_sync_tool") + ); + assert!( + tools + .iter() + .any(|tool| tool_name(&tool.spec) == "read_file") + ); + assert!( + tools + .iter() + .any(|tool| tool_name(&tool.spec) == "grep_files") + ); + assert!(tools.iter().any(|tool| tool_name(&tool.spec) == "list_dir")); +} + +#[test] +fn test_build_specs_mcp_tools_converted() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs( + &tools_config, + Some(HashMap::from([( + "test_server/do_something_cool".to_string(), + mcp_tool( + "do_something_cool", + "Do something cool", + serde_json::json!({ + "type": "object", + "properties": { + "string_argument": { "type": "string" }, + "number_argument": { "type": "number" }, + "object_argument": { + "type": "object", + "properties": { + "string_property": { "type": "string" }, + "number_property": { "type": "number" }, + }, + "required": ["string_property", "number_property"], + "additionalProperties": false, + }, + }, + }), + ), + )])), + None, + &[], + ) + .build(); + + let tool = find_tool(&tools, "test_server/do_something_cool"); + assert_eq!( + &tool.spec, + &ToolSpec::Function(ResponsesApiTool { + name: "test_server/do_something_cool".to_string(), + parameters: JsonSchema::Object { + properties: BTreeMap::from([ + ( + "string_argument".to_string(), + JsonSchema::String { description: None } + ), + ( + "number_argument".to_string(), + JsonSchema::Number { description: None } + ), + ( + "object_argument".to_string(), + JsonSchema::Object { + properties: BTreeMap::from([ + ( + "string_property".to_string(), + JsonSchema::String { description: None } + ), + ( + "number_property".to_string(), + JsonSchema::Number { description: None } + ), + ]), + required: Some(vec![ + "string_property".to_string(), + "number_property".to_string(), + ]), + additional_properties: Some(false.into()), + }, + ), + ]), + required: None, + additional_properties: None, + }, + description: "Do something cool".to_string(), + strict: false, + output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, + }) + ); +} + +#[test] +fn test_build_specs_mcp_tools_sorted_by_name() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + // Intentionally construct a map with keys that would sort alphabetically. + let tools_map: HashMap = HashMap::from([ + ( + "test_server/do".to_string(), + mcp_tool("a", "a", serde_json::json!({"type": "object"})), + ), + ( + "test_server/something".to_string(), + mcp_tool("b", "b", serde_json::json!({"type": "object"})), + ), + ( + "test_server/cool".to_string(), + mcp_tool("c", "c", serde_json::json!({"type": "object"})), + ), + ]); + + let (tools, _) = build_specs(&tools_config, Some(tools_map), None, &[]).build(); + + // Only assert that the MCP tools themselves are sorted by fully-qualified name. + let mcp_names: Vec<_> = tools + .iter() + .map(|t| tool_name(&t.spec).to_string()) + .filter(|n| n.starts_with("test_server/")) + .collect(); + let expected = vec![ + "test_server/cool".to_string(), + "test_server/do".to_string(), + "test_server/something".to_string(), + ]; + assert_eq!(mcp_names, expected); +} + +#[test] +fn search_tool_description_includes_only_codex_apps_connector_names() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Apps); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let (tools, _) = build_specs( + &tools_config, + Some(HashMap::from([ + ( + "mcp__codex_apps__calendar_create_event".to_string(), + mcp_tool( + "calendar_create_event", + "Create calendar event", + serde_json::json!({"type": "object"}), + ), + ), + ( + "mcp__rmcp__echo".to_string(), + mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), + ), + ])), + Some(HashMap::from([ + ( + "mcp__codex_apps__calendar-create-event".to_string(), + ToolInfo { + server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-create-event".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: mcp_tool( + "calendar-create-event", + "Create calendar event", + serde_json::json!({"type": "object"}), + ), + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + plugin_display_names: Vec::new(), + connector_description: None, + }, + ), + ( + "mcp__rmcp__echo".to_string(), + ToolInfo { + server_name: "rmcp".to_string(), + tool_name: "echo".to_string(), + tool_namespace: "rmcp".to_string(), + tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), + connector_id: None, + connector_name: None, + plugin_display_names: Vec::new(), + connector_description: None, + }, + ), + ])), + &[], + ) + .build(); + + let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); + let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else { + panic!("expected tool_search tool"); + }; + let description = description.as_str(); + assert!(description.contains("Calendar")); + assert!(!description.contains("mcp__rmcp__echo")); +} + +#[test] +fn search_tool_requires_apps_feature_flag_only() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let app_tools = Some(HashMap::from([( + "mcp__codex_apps__calendar_create_event".to_string(), + ToolInfo { + server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "calendar_create_event".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: mcp_tool( + "calendar_create_event", + "Create calendar event", + serde_json::json!({"type": "object"}), + ), + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: None, + plugin_display_names: Vec::new(), + }, + )])); + + let features = Features::with_defaults(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); + assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + let mut features = Features::with_defaults(); + features.enable(Feature::Apps); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, app_tools, &[]).build(); + assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]); +} + +#[test] +fn tool_suggest_is_not_registered_without_feature_flag() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Apps); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs_with_discoverable_tools( + &tools_config, + None, + None, + Some(vec![discoverable_connector( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + "Plan events and schedules.", + )]), + &[], + ) + .build(); + + assert!( + !tools + .iter() + .any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME) + ); +} + +#[test] +fn search_tool_description_handles_no_enabled_apps() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Apps); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let (tools, _) = build_specs(&tools_config, None, Some(HashMap::new()), &[]).build(); + let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); + let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else { + panic!("expected tool_search tool"); + }; + + assert!(description.contains("(None currently enabled)")); + assert!(!description.contains("{{app_names}}")); +} + +#[test] +fn search_tool_registers_namespaced_app_tool_aliases() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Apps); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let (_, registry) = build_specs( + &tools_config, + None, + Some(HashMap::from([ + ( + "mcp__codex_apps__calendar-create-event".to_string(), + ToolInfo { + server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-create-event".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: mcp_tool( + "calendar-create-event", + "Create calendar event", + serde_json::json!({"type": "object"}), + ), + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: None, + plugin_display_names: Vec::new(), + }, + ), + ( + "mcp__codex_apps__calendar-list-events".to_string(), + ToolInfo { + server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "-list-events".to_string(), + tool_namespace: "mcp__codex_apps__calendar".to_string(), + tool: mcp_tool( + "calendar-list-events", + "List calendar events", + serde_json::json!({"type": "object"}), + ), + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: None, + plugin_display_names: Vec::new(), + }, + ), + ])), + &[], + ) + .build(); + + let alias = tool_handler_key("-create-event", Some("mcp__codex_apps__calendar")); + + assert!(registry.has_handler(TOOL_SEARCH_TOOL_NAME, None)); + assert!(registry.has_handler(alias.as_str(), None)); +} + +#[test] +fn tool_suggest_description_lists_discoverable_tools() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Apps); + features.enable(Feature::ToolSuggest); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let discoverable_tools = vec![ + discoverable_connector( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + "Plan events and schedules.", + ), + discoverable_connector( + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail", + "Find and summarize email threads.", + ), + DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "sample@test".to_string(), + name: "Sample Plugin".to_string(), + description: None, + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_sample".to_string()], + })), + ]; + + let (tools, _) = build_specs_with_discoverable_tools( + &tools_config, + None, + None, + Some(discoverable_tools), + &[], + ) + .build(); + + let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME); + let ToolSpec::Function(ResponsesApiTool { + description, + parameters, + .. + }) = &tool_suggest.spec + else { + panic!("expected function tool"); + }; + assert!(description.contains("Google Calendar")); + assert!(description.contains("Gmail")); + assert!(description.contains("Sample Plugin")); + assert!(description.contains("Plan events and schedules.")); + assert!(description.contains("Find and summarize email threads.")); + assert!(description.contains("id: `sample@test`, type: plugin, action: enable")); + assert!( + description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample") + ); + assert!(description.contains("DO NOT explore or recommend tools that are not on this list.")); + let JsonSchema::Object { required, .. } = parameters else { + panic!("expected object parameters"); + }; + assert_eq!( + required.as_ref(), + Some(&vec![ + "tool_type".to_string(), + "action_type".to_string(), + "tool_id".to_string(), + "suggest_reason".to_string(), + ]) + ); +} + +#[test] +fn test_mcp_tool_property_missing_type_defaults_to_string() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let (tools, _) = build_specs( + &tools_config, + Some(HashMap::from([( + "dash/search".to_string(), + mcp_tool( + "search", + "Search docs", + serde_json::json!({ + "type": "object", + "properties": { + "query": {"description": "search query"} + } + }), + ), + )])), + None, + &[], + ) + .build(); + + let tool = find_tool(&tools, "dash/search"); + assert_eq!( + tool.spec, + ToolSpec::Function(ResponsesApiTool { + name: "dash/search".to_string(), + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "query".to_string(), + JsonSchema::String { + description: Some("search query".to_string()) + } + )]), + required: None, + additional_properties: None, + }, + description: "Search docs".to_string(), + strict: false, + output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, + }) + ); +} + +#[test] +fn test_mcp_tool_integer_normalized_to_number() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let (tools, _) = build_specs( + &tools_config, + Some(HashMap::from([( + "dash/paginate".to_string(), + mcp_tool( + "paginate", + "Pagination", + serde_json::json!({ + "type": "object", + "properties": {"page": {"type": "integer"}} + }), + ), + )])), + None, + &[], + ) + .build(); + + let tool = find_tool(&tools, "dash/paginate"); + assert_eq!( + tool.spec, + ToolSpec::Function(ResponsesApiTool { + name: "dash/paginate".to_string(), + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "page".to_string(), + JsonSchema::Number { description: None } + )]), + required: None, + additional_properties: None, + }, + description: "Pagination".to_string(), + strict: false, + output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, + }) + ); +} + +#[test] +fn test_mcp_tool_array_without_items_gets_default_string_items() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::ApplyPatchFreeform); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let (tools, _) = build_specs( + &tools_config, + Some(HashMap::from([( + "dash/tags".to_string(), + mcp_tool( + "tags", + "Tags", + serde_json::json!({ + "type": "object", + "properties": {"tags": {"type": "array"}} + }), + ), + )])), + None, + &[], + ) + .build(); + + let tool = find_tool(&tools, "dash/tags"); + assert_eq!( + tool.spec, + ToolSpec::Function(ResponsesApiTool { + name: "dash/tags".to_string(), + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "tags".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: None + } + )]), + required: None, + additional_properties: None, + }, + description: "Tags".to_string(), + strict: false, + output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, + }) + ); +} + +#[test] +fn test_mcp_tool_anyof_defaults_to_string() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let (tools, _) = build_specs( + &tools_config, + Some(HashMap::from([( + "dash/value".to_string(), + mcp_tool( + "value", + "AnyOf Value", + serde_json::json!({ + "type": "object", + "properties": { + "value": {"anyOf": [{"type": "string"}, {"type": "number"}]} + } + }), + ), + )])), + None, + &[], + ) + .build(); + + let tool = find_tool(&tools, "dash/value"); + assert_eq!( + tool.spec, + ToolSpec::Function(ResponsesApiTool { + name: "dash/value".to_string(), + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "value".to_string(), + JsonSchema::String { description: None } + )]), + required: None, + additional_properties: None, + }, + description: "AnyOf Value".to_string(), + strict: false, + output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, + }) + ); +} + +#[test] +fn test_shell_tool() { + let tool = super::create_shell_tool(false); + let ToolSpec::Function(ResponsesApiTool { + description, name, .. + }) = &tool + else { + panic!("expected function tool"); + }; + assert_eq!(name, "shell"); + + let expected = if cfg!(windows) { + r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. + +Examples of valid command strings: + +- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] +- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] +- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] +- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"] +- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] +- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"# + } else { + r#"Runs a shell command and returns its output. +- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. +- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# + }.to_string(); + assert_eq!(description, &expected); +} + +#[test] +fn shell_tool_with_request_permission_includes_additional_permissions() { + let tool = super::create_shell_tool(true); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { + panic!("expected function tool"); + }; + let JsonSchema::Object { properties, .. } = parameters else { + panic!("expected object parameters"); + }; + + assert!(properties.contains_key("additional_permissions")); + + let Some(JsonSchema::String { + description: Some(description), + }) = properties.get("sandbox_permissions") + else { + panic!("expected sandbox_permissions description"); + }; + assert!(description.contains("with_additional_permissions")); + assert!(description.contains("macOS permissions")); + + let Some(JsonSchema::Object { + properties: additional_properties, + .. + }) = properties.get("additional_permissions") + else { + panic!("expected additional_permissions schema"); + }; + assert!(additional_properties.contains_key("network")); + assert!(additional_properties.contains_key("file_system")); + assert!(additional_properties.contains_key("macos")); +} + +#[test] +fn request_permissions_tool_includes_full_permission_schema() { + let tool = super::create_request_permissions_tool(); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { + panic!("expected function tool"); + }; + let JsonSchema::Object { properties, .. } = parameters else { + panic!("expected object parameters"); + }; + let Some(JsonSchema::Object { + properties: permission_properties, + additional_properties, + .. + }) = properties.get("permissions") + else { + panic!("expected permissions object"); + }; + + assert_eq!(additional_properties, &Some(false.into())); + assert!(permission_properties.contains_key("network")); + assert!(permission_properties.contains_key("file_system")); + assert!(permission_properties.contains_key("macos")); + + let Some(JsonSchema::Object { + properties: network_properties, + additional_properties, + .. + }) = permission_properties.get("network") + else { + panic!("expected network object"); + }; + assert_eq!(additional_properties, &Some(false.into())); + assert!(network_properties.contains_key("enabled")); + + let Some(JsonSchema::Object { + properties: file_system_properties, + additional_properties, + .. + }) = permission_properties.get("file_system") + else { + panic!("expected file_system object"); + }; + assert_eq!(additional_properties, &Some(false.into())); + assert!(file_system_properties.contains_key("read")); + assert!(file_system_properties.contains_key("write")); + + let Some(JsonSchema::Object { + properties: macos_properties, + additional_properties, + .. + }) = permission_properties.get("macos") + else { + panic!("expected macos object"); + }; + assert_eq!(additional_properties, &Some(false.into())); + assert!(macos_properties.contains_key("preferences")); + assert!(macos_properties.contains_key("automations")); + assert!(macos_properties.contains_key("accessibility")); + assert!(macos_properties.contains_key("calendar")); +} + +#[test] +fn test_shell_command_tool() { + let tool = super::create_shell_command_tool(true, false); + let ToolSpec::Function(ResponsesApiTool { + description, name, .. + }) = &tool + else { + panic!("expected function tool"); + }; + assert_eq!(name, "shell_command"); + + let expected = if cfg!(windows) { + r#"Runs a Powershell command (Windows) and returns its output. + +Examples of valid command strings: + +- ls -a (show hidden): "Get-ChildItem -Force" +- recursive find by name: "Get-ChildItem -Recurse -Filter *.py" +- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive" +- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }" +- setting an env var: "$env:FOO='bar'; echo $env:FOO" +- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -"#.to_string() + } else { + r#"Runs a shell command and returns its output. +- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#.to_string() + }; + assert_eq!(description, &expected); +} + +#[test] +fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs( + &tools_config, + Some(HashMap::from([( + "test_server/do_something_cool".to_string(), + mcp_tool( + "do_something_cool", + "Do something cool", + serde_json::json!({ + "type": "object", + "properties": { + "string_argument": {"type": "string"}, + "number_argument": {"type": "number"}, + "object_argument": { + "type": "object", + "properties": { + "string_property": {"type": "string"}, + "number_property": {"type": "number"} + }, + "required": ["string_property", "number_property"], + "additionalProperties": { + "type": "object", + "properties": { + "addtl_prop": {"type": "string"} + }, + "required": ["addtl_prop"], + "additionalProperties": false + } + } + } + }), + ), + )])), + None, + &[], + ) + .build(); + + let tool = find_tool(&tools, "test_server/do_something_cool"); + assert_eq!( + tool.spec, + ToolSpec::Function(ResponsesApiTool { + name: "test_server/do_something_cool".to_string(), + parameters: JsonSchema::Object { + properties: BTreeMap::from([ + ( + "string_argument".to_string(), + JsonSchema::String { description: None } + ), + ( + "number_argument".to_string(), + JsonSchema::Number { description: None } + ), + ( + "object_argument".to_string(), + JsonSchema::Object { + properties: BTreeMap::from([ + ( + "string_property".to_string(), + JsonSchema::String { description: None } + ), + ( + "number_property".to_string(), + JsonSchema::Number { description: None } + ), + ]), + required: Some(vec![ + "string_property".to_string(), + "number_property".to_string(), + ]), + additional_properties: Some( + JsonSchema::Object { + properties: BTreeMap::from([( + "addtl_prop".to_string(), + JsonSchema::String { description: None } + ),]), + required: Some(vec!["addtl_prop".to_string(),]), + additional_properties: Some(false.into()), + } + .into() + ), + }, + ), + ]), + required: None, + additional_properties: None, + }, + description: "Do something cool".to_string(), + strict: false, + output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), + defer_loading: None, + }) + ); +} + +#[test] +fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let ToolSpec::Function(ResponsesApiTool { description, .. }) = + &find_tool(&tools, "view_image").spec + else { + panic!("expected function tool"); + }; + + assert_eq!( + description, + "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nCode mode declaration:\n```ts\nimport { view_image } from \"tools.js\";\ndeclare function view_image(args: {\n path: string;\n}): Promise;\n```" + ); +} + +#[test] +fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + features.enable(Feature::UnifiedExec); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let (tools, _) = build_specs( + &tools_config, + Some(HashMap::from([( + "mcp__sample__echo".to_string(), + mcp_tool( + "echo", + "Echo text", + serde_json::json!({ + "type": "object", + "properties": { + "message": {"type": "string"} + }, + "required": ["message"], + "additionalProperties": false + }), + ), + )])), + None, + &[], + ) + .build(); + + let ToolSpec::Function(ResponsesApiTool { description, .. }) = + &find_tool(&tools, "mcp__sample__echo").spec + else { + panic!("expected function tool"); + }; + + assert_eq!( + description, + "Echo text\n\nCode mode declaration:\n```ts\nimport { echo } from \"tools/mcp/sample.js\";\ndeclare function echo(args: {\n message: string;\n}): Promise<{\n _meta?: unknown;\n content: Array;\n isError?: boolean;\n structuredContent?: unknown;\n}>;\n```" + ); +} + +#[test] +fn chat_tools_include_top_level_name() { + let properties = + BTreeMap::from([("foo".to_string(), JsonSchema::String { description: None })]); + let tools = vec![ToolSpec::Function(ResponsesApiTool { + name: "demo".to_string(), + description: "A demo tool".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: None, + }, + output_schema: None, + })]; + + let responses_json = create_tools_json_for_responses_api(&tools).unwrap(); + assert_eq!( + responses_json, + vec![json!({ + "type": "function", + "name": "demo", + "description": "A demo tool", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "foo": { "type": "string" } + }, + }, + })] + ); +} diff --git a/codex-rs/core/src/truncate.rs b/codex-rs/core/src/truncate.rs index 927d7c9380..707fbe22ef 100644 --- a/codex-rs/core/src/truncate.rs +++ b/codex-rs/core/src/truncate.rs @@ -359,319 +359,5 @@ pub(crate) fn approx_tokens_from_byte_count_i64(bytes: i64) -> i64 { } #[cfg(test)] -mod tests { - - use super::TruncationPolicy; - use super::approx_token_count; - use super::formatted_truncate_text; - use super::formatted_truncate_text_content_items_with_policy; - use super::split_string; - use super::truncate_function_output_items_with_policy; - use super::truncate_text; - use super::truncate_with_token_budget; - use codex_protocol::models::FunctionCallOutputContentItem; - use pretty_assertions::assert_eq; - - #[test] - fn split_string_works() { - assert_eq!(split_string("hello world", 5, 5), (1, "hello", "world")); - assert_eq!(split_string("abc", 0, 0), (3, "", "")); - } - - #[test] - fn split_string_handles_empty_string() { - assert_eq!(split_string("", 4, 4), (0, "", "")); - } - - #[test] - fn split_string_only_keeps_prefix_when_tail_budget_is_zero() { - assert_eq!(split_string("abcdef", 3, 0), (3, "abc", "")); - } - - #[test] - fn split_string_only_keeps_suffix_when_prefix_budget_is_zero() { - assert_eq!(split_string("abcdef", 0, 3), (3, "", "def")); - } - - #[test] - fn split_string_handles_overlapping_budgets_without_removal() { - assert_eq!(split_string("abcdef", 4, 4), (0, "abcd", "ef")); - } - - #[test] - fn split_string_respects_utf8_boundaries() { - assert_eq!(split_string("😀abc😀", 5, 5), (1, "😀a", "c😀")); - - assert_eq!(split_string("😀😀😀😀😀", 1, 1), (5, "", "")); - assert_eq!(split_string("😀😀😀😀😀", 7, 7), (3, "😀", "😀")); - assert_eq!(split_string("😀😀😀😀😀", 8, 8), (1, "😀😀", "😀😀")); - } - - #[test] - fn truncate_bytes_less_than_placeholder_returns_placeholder() { - let content = "example output"; - - assert_eq!( - "Total output lines: 1\n\n…13 chars truncated…t", - formatted_truncate_text(content, TruncationPolicy::Bytes(1)), - ); - } - - #[test] - fn truncate_tokens_less_than_placeholder_returns_placeholder() { - let content = "example output"; - - assert_eq!( - "Total output lines: 1\n\nex…3 tokens truncated…ut", - formatted_truncate_text(content, TruncationPolicy::Tokens(1)), - ); - } - - #[test] - fn truncate_tokens_under_limit_returns_original() { - let content = "example output"; - - assert_eq!( - content, - formatted_truncate_text(content, TruncationPolicy::Tokens(10)), - ); - } - - #[test] - fn truncate_bytes_under_limit_returns_original() { - let content = "example output"; - - assert_eq!( - content, - formatted_truncate_text(content, TruncationPolicy::Bytes(20)), - ); - } - - #[test] - fn truncate_tokens_over_limit_returns_truncated() { - let content = "this is an example of a long output that should be truncated"; - - assert_eq!( - "Total output lines: 1\n\nthis is an…10 tokens truncated… truncated", - formatted_truncate_text(content, TruncationPolicy::Tokens(5)), - ); - } - - #[test] - fn truncate_bytes_over_limit_returns_truncated() { - let content = "this is an example of a long output that should be truncated"; - - assert_eq!( - "Total output lines: 1\n\nthis is an exam…30 chars truncated…ld be truncated", - formatted_truncate_text(content, TruncationPolicy::Bytes(30)), - ); - } - - #[test] - fn truncate_bytes_reports_original_line_count_when_truncated() { - let content = - "this is an example of a long output that should be truncated\nalso some other line"; - - assert_eq!( - "Total output lines: 2\n\nthis is an exam…51 chars truncated…some other line", - formatted_truncate_text(content, TruncationPolicy::Bytes(30)), - ); - } - - #[test] - fn truncate_tokens_reports_original_line_count_when_truncated() { - let content = - "this is an example of a long output that should be truncated\nalso some other line"; - - assert_eq!( - "Total output lines: 2\n\nthis is an example o…11 tokens truncated…also some other line", - formatted_truncate_text(content, TruncationPolicy::Tokens(10)), - ); - } - - #[test] - fn truncate_with_token_budget_returns_original_when_under_limit() { - let s = "short output"; - let limit = 100; - let (out, original) = truncate_with_token_budget(s, TruncationPolicy::Tokens(limit)); - assert_eq!(out, s); - assert_eq!(original, None); - } - - #[test] - fn truncate_with_token_budget_reports_truncation_at_zero_limit() { - let s = "abcdef"; - let (out, original) = truncate_with_token_budget(s, TruncationPolicy::Tokens(0)); - assert_eq!(out, "…2 tokens truncated…"); - assert_eq!(original, Some(2)); - } - - #[test] - fn truncate_middle_tokens_handles_utf8_content() { - let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with text\n"; - let (out, tokens) = truncate_with_token_budget(s, TruncationPolicy::Tokens(8)); - assert_eq!(out, "😀😀😀😀…8 tokens truncated… line with text\n"); - assert_eq!(tokens, Some(16)); - } - - #[test] - fn truncate_middle_bytes_handles_utf8_content() { - let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with text\n"; - let out = truncate_text(s, TruncationPolicy::Bytes(20)); - assert_eq!(out, "😀😀…21 chars truncated…with text\n"); - } - - #[test] - fn truncates_across_multiple_under_limit_texts_and_reports_omitted() { - let chunk = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega.\n"; - let chunk_tokens = approx_token_count(chunk); - assert!(chunk_tokens > 0, "chunk must consume tokens"); - let limit = chunk_tokens * 3; - let t1 = chunk.to_string(); - let t2 = chunk.to_string(); - let t3 = chunk.repeat(10); - let t4 = chunk.to_string(); - let t5 = chunk.to_string(); - - let items = vec![ - FunctionCallOutputContentItem::InputText { text: t1.clone() }, - FunctionCallOutputContentItem::InputText { text: t2.clone() }, - FunctionCallOutputContentItem::InputImage { - image_url: "img:mid".to_string(), - detail: None, - }, - FunctionCallOutputContentItem::InputText { text: t3 }, - FunctionCallOutputContentItem::InputText { text: t4 }, - FunctionCallOutputContentItem::InputText { text: t5 }, - ]; - - let output = - truncate_function_output_items_with_policy(&items, TruncationPolicy::Tokens(limit)); - - // Expect: t1 (full), t2 (full), image, t3 (truncated), summary mentioning 2 omitted. - assert_eq!(output.len(), 5); - - let first_text = match &output[0] { - FunctionCallOutputContentItem::InputText { text } => text, - other => panic!("unexpected first item: {other:?}"), - }; - assert_eq!(first_text, &t1); - - let second_text = match &output[1] { - FunctionCallOutputContentItem::InputText { text } => text, - other => panic!("unexpected second item: {other:?}"), - }; - assert_eq!(second_text, &t2); - - assert_eq!( - output[2], - FunctionCallOutputContentItem::InputImage { - image_url: "img:mid".to_string(), - detail: None, - } - ); - - let fourth_text = match &output[3] { - FunctionCallOutputContentItem::InputText { text } => text, - other => panic!("unexpected fourth item: {other:?}"), - }; - assert!( - fourth_text.contains("tokens truncated"), - "expected marker in truncated snippet: {fourth_text}" - ); - - let summary_text = match &output[4] { - FunctionCallOutputContentItem::InputText { text } => text, - other => panic!("unexpected summary item: {other:?}"), - }; - assert!(summary_text.contains("omitted 2 text items")); - } - - #[test] - fn formatted_truncate_text_content_items_with_policy_returns_original_under_limit() { - let items = vec![ - FunctionCallOutputContentItem::InputText { - text: "alpha".to_string(), - }, - FunctionCallOutputContentItem::InputText { - text: String::new(), - }, - FunctionCallOutputContentItem::InputText { - text: "beta".to_string(), - }, - ]; - - let (output, original_token_count) = - formatted_truncate_text_content_items_with_policy(&items, TruncationPolicy::Bytes(32)); - - assert_eq!(output, items); - assert_eq!(original_token_count, None); - } - - #[test] - fn formatted_truncate_text_content_items_with_policy_merges_text_and_appends_images() { - let items = vec![ - FunctionCallOutputContentItem::InputText { - text: "abcd".to_string(), - }, - FunctionCallOutputContentItem::InputImage { - image_url: "img:one".to_string(), - detail: None, - }, - FunctionCallOutputContentItem::InputText { - text: "efgh".to_string(), - }, - FunctionCallOutputContentItem::InputText { - text: "ijkl".to_string(), - }, - FunctionCallOutputContentItem::InputImage { - image_url: "img:two".to_string(), - detail: None, - }, - ]; - - let (output, original_token_count) = - formatted_truncate_text_content_items_with_policy(&items, TruncationPolicy::Bytes(8)); - - assert_eq!( - output, - vec![ - FunctionCallOutputContentItem::InputText { - text: "Total output lines: 3\n\nabcd…6 chars truncated…ijkl".to_string(), - }, - FunctionCallOutputContentItem::InputImage { - image_url: "img:one".to_string(), - detail: None, - }, - FunctionCallOutputContentItem::InputImage { - image_url: "img:two".to_string(), - detail: None, - }, - ] - ); - assert_eq!(original_token_count, Some(4)); - } - - #[test] - fn formatted_truncate_text_content_items_with_policy_merges_all_text_for_token_budget() { - let items = vec![ - FunctionCallOutputContentItem::InputText { - text: "abcdefgh".to_string(), - }, - FunctionCallOutputContentItem::InputText { - text: "ijklmnop".to_string(), - }, - ]; - - let (output, original_token_count) = - formatted_truncate_text_content_items_with_policy(&items, TruncationPolicy::Tokens(2)); - - assert_eq!( - output, - vec![FunctionCallOutputContentItem::InputText { - text: "Total output lines: 2\n\nabcd…3 tokens truncated…mnop".to_string(), - }] - ); - assert_eq!(original_token_count, Some(5)); - } -} +#[path = "truncate_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/truncate_tests.rs b/codex-rs/core/src/truncate_tests.rs new file mode 100644 index 0000000000..5a61a9a26d --- /dev/null +++ b/codex-rs/core/src/truncate_tests.rs @@ -0,0 +1,313 @@ +use super::TruncationPolicy; +use super::approx_token_count; +use super::formatted_truncate_text; +use super::formatted_truncate_text_content_items_with_policy; +use super::split_string; +use super::truncate_function_output_items_with_policy; +use super::truncate_text; +use super::truncate_with_token_budget; +use codex_protocol::models::FunctionCallOutputContentItem; +use pretty_assertions::assert_eq; + +#[test] +fn split_string_works() { + assert_eq!(split_string("hello world", 5, 5), (1, "hello", "world")); + assert_eq!(split_string("abc", 0, 0), (3, "", "")); +} + +#[test] +fn split_string_handles_empty_string() { + assert_eq!(split_string("", 4, 4), (0, "", "")); +} + +#[test] +fn split_string_only_keeps_prefix_when_tail_budget_is_zero() { + assert_eq!(split_string("abcdef", 3, 0), (3, "abc", "")); +} + +#[test] +fn split_string_only_keeps_suffix_when_prefix_budget_is_zero() { + assert_eq!(split_string("abcdef", 0, 3), (3, "", "def")); +} + +#[test] +fn split_string_handles_overlapping_budgets_without_removal() { + assert_eq!(split_string("abcdef", 4, 4), (0, "abcd", "ef")); +} + +#[test] +fn split_string_respects_utf8_boundaries() { + assert_eq!(split_string("😀abc😀", 5, 5), (1, "😀a", "c😀")); + + assert_eq!(split_string("😀😀😀😀😀", 1, 1), (5, "", "")); + assert_eq!(split_string("😀😀😀😀😀", 7, 7), (3, "😀", "😀")); + assert_eq!(split_string("😀😀😀😀😀", 8, 8), (1, "😀😀", "😀😀")); +} + +#[test] +fn truncate_bytes_less_than_placeholder_returns_placeholder() { + let content = "example output"; + + assert_eq!( + "Total output lines: 1\n\n…13 chars truncated…t", + formatted_truncate_text(content, TruncationPolicy::Bytes(1)), + ); +} + +#[test] +fn truncate_tokens_less_than_placeholder_returns_placeholder() { + let content = "example output"; + + assert_eq!( + "Total output lines: 1\n\nex…3 tokens truncated…ut", + formatted_truncate_text(content, TruncationPolicy::Tokens(1)), + ); +} + +#[test] +fn truncate_tokens_under_limit_returns_original() { + let content = "example output"; + + assert_eq!( + content, + formatted_truncate_text(content, TruncationPolicy::Tokens(10)), + ); +} + +#[test] +fn truncate_bytes_under_limit_returns_original() { + let content = "example output"; + + assert_eq!( + content, + formatted_truncate_text(content, TruncationPolicy::Bytes(20)), + ); +} + +#[test] +fn truncate_tokens_over_limit_returns_truncated() { + let content = "this is an example of a long output that should be truncated"; + + assert_eq!( + "Total output lines: 1\n\nthis is an…10 tokens truncated… truncated", + formatted_truncate_text(content, TruncationPolicy::Tokens(5)), + ); +} + +#[test] +fn truncate_bytes_over_limit_returns_truncated() { + let content = "this is an example of a long output that should be truncated"; + + assert_eq!( + "Total output lines: 1\n\nthis is an exam…30 chars truncated…ld be truncated", + formatted_truncate_text(content, TruncationPolicy::Bytes(30)), + ); +} + +#[test] +fn truncate_bytes_reports_original_line_count_when_truncated() { + let content = + "this is an example of a long output that should be truncated\nalso some other line"; + + assert_eq!( + "Total output lines: 2\n\nthis is an exam…51 chars truncated…some other line", + formatted_truncate_text(content, TruncationPolicy::Bytes(30)), + ); +} + +#[test] +fn truncate_tokens_reports_original_line_count_when_truncated() { + let content = + "this is an example of a long output that should be truncated\nalso some other line"; + + assert_eq!( + "Total output lines: 2\n\nthis is an example o…11 tokens truncated…also some other line", + formatted_truncate_text(content, TruncationPolicy::Tokens(10)), + ); +} + +#[test] +fn truncate_with_token_budget_returns_original_when_under_limit() { + let s = "short output"; + let limit = 100; + let (out, original) = truncate_with_token_budget(s, TruncationPolicy::Tokens(limit)); + assert_eq!(out, s); + assert_eq!(original, None); +} + +#[test] +fn truncate_with_token_budget_reports_truncation_at_zero_limit() { + let s = "abcdef"; + let (out, original) = truncate_with_token_budget(s, TruncationPolicy::Tokens(0)); + assert_eq!(out, "…2 tokens truncated…"); + assert_eq!(original, Some(2)); +} + +#[test] +fn truncate_middle_tokens_handles_utf8_content() { + let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with text\n"; + let (out, tokens) = truncate_with_token_budget(s, TruncationPolicy::Tokens(8)); + assert_eq!(out, "😀😀😀😀…8 tokens truncated… line with text\n"); + assert_eq!(tokens, Some(16)); +} + +#[test] +fn truncate_middle_bytes_handles_utf8_content() { + let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with text\n"; + let out = truncate_text(s, TruncationPolicy::Bytes(20)); + assert_eq!(out, "😀😀…21 chars truncated…with text\n"); +} + +#[test] +fn truncates_across_multiple_under_limit_texts_and_reports_omitted() { + let chunk = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega.\n"; + let chunk_tokens = approx_token_count(chunk); + assert!(chunk_tokens > 0, "chunk must consume tokens"); + let limit = chunk_tokens * 3; + let t1 = chunk.to_string(); + let t2 = chunk.to_string(); + let t3 = chunk.repeat(10); + let t4 = chunk.to_string(); + let t5 = chunk.to_string(); + + let items = vec![ + FunctionCallOutputContentItem::InputText { text: t1.clone() }, + FunctionCallOutputContentItem::InputText { text: t2.clone() }, + FunctionCallOutputContentItem::InputImage { + image_url: "img:mid".to_string(), + detail: None, + }, + FunctionCallOutputContentItem::InputText { text: t3 }, + FunctionCallOutputContentItem::InputText { text: t4 }, + FunctionCallOutputContentItem::InputText { text: t5 }, + ]; + + let output = + truncate_function_output_items_with_policy(&items, TruncationPolicy::Tokens(limit)); + + // Expect: t1 (full), t2 (full), image, t3 (truncated), summary mentioning 2 omitted. + assert_eq!(output.len(), 5); + + let first_text = match &output[0] { + FunctionCallOutputContentItem::InputText { text } => text, + other => panic!("unexpected first item: {other:?}"), + }; + assert_eq!(first_text, &t1); + + let second_text = match &output[1] { + FunctionCallOutputContentItem::InputText { text } => text, + other => panic!("unexpected second item: {other:?}"), + }; + assert_eq!(second_text, &t2); + + assert_eq!( + output[2], + FunctionCallOutputContentItem::InputImage { + image_url: "img:mid".to_string(), + detail: None, + } + ); + + let fourth_text = match &output[3] { + FunctionCallOutputContentItem::InputText { text } => text, + other => panic!("unexpected fourth item: {other:?}"), + }; + assert!( + fourth_text.contains("tokens truncated"), + "expected marker in truncated snippet: {fourth_text}" + ); + + let summary_text = match &output[4] { + FunctionCallOutputContentItem::InputText { text } => text, + other => panic!("unexpected summary item: {other:?}"), + }; + assert!(summary_text.contains("omitted 2 text items")); +} + +#[test] +fn formatted_truncate_text_content_items_with_policy_returns_original_under_limit() { + let items = vec![ + FunctionCallOutputContentItem::InputText { + text: "alpha".to_string(), + }, + FunctionCallOutputContentItem::InputText { + text: String::new(), + }, + FunctionCallOutputContentItem::InputText { + text: "beta".to_string(), + }, + ]; + + let (output, original_token_count) = + formatted_truncate_text_content_items_with_policy(&items, TruncationPolicy::Bytes(32)); + + assert_eq!(output, items); + assert_eq!(original_token_count, None); +} + +#[test] +fn formatted_truncate_text_content_items_with_policy_merges_text_and_appends_images() { + let items = vec![ + FunctionCallOutputContentItem::InputText { + text: "abcd".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "img:one".to_string(), + detail: None, + }, + FunctionCallOutputContentItem::InputText { + text: "efgh".to_string(), + }, + FunctionCallOutputContentItem::InputText { + text: "ijkl".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "img:two".to_string(), + detail: None, + }, + ]; + + let (output, original_token_count) = + formatted_truncate_text_content_items_with_policy(&items, TruncationPolicy::Bytes(8)); + + assert_eq!( + output, + vec![ + FunctionCallOutputContentItem::InputText { + text: "Total output lines: 3\n\nabcd…6 chars truncated…ijkl".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "img:one".to_string(), + detail: None, + }, + FunctionCallOutputContentItem::InputImage { + image_url: "img:two".to_string(), + detail: None, + }, + ] + ); + assert_eq!(original_token_count, Some(4)); +} + +#[test] +fn formatted_truncate_text_content_items_with_policy_merges_all_text_for_token_budget() { + let items = vec![ + FunctionCallOutputContentItem::InputText { + text: "abcdefgh".to_string(), + }, + FunctionCallOutputContentItem::InputText { + text: "ijklmnop".to_string(), + }, + ]; + + let (output, original_token_count) = + formatted_truncate_text_content_items_with_policy(&items, TruncationPolicy::Tokens(2)); + + assert_eq!( + output, + vec![FunctionCallOutputContentItem::InputText { + text: "Total output lines: 2\n\nabcd…3 tokens truncated…mnop".to_string(), + }] + ); + assert_eq!(original_token_count, Some(5)); +} diff --git a/codex-rs/core/src/turn_diff_tracker.rs b/codex-rs/core/src/turn_diff_tracker.rs index 06c40deb90..3568c915af 100644 --- a/codex-rs/core/src/turn_diff_tracker.rs +++ b/codex-rs/core/src/turn_diff_tracker.rs @@ -465,432 +465,5 @@ fn is_windows_drive_or_unc_root(p: &std::path::Path) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::tempdir; - - /// Compute the Git SHA-1 blob object ID for the given content (string). - /// This delegates to the bytes version to avoid UTF-8 lossy conversions here. - fn git_blob_sha1_hex(data: &str) -> String { - format!("{:x}", git_blob_sha1_hex_bytes(data.as_bytes())) - } - - fn normalize_diff_for_test(input: &str, root: &Path) -> String { - let root_str = root.display().to_string().replace('\\', "/"); - let replaced = input.replace(&root_str, ""); - // Split into blocks on lines starting with "diff --git ", sort blocks for determinism, and rejoin - let mut blocks: Vec = Vec::new(); - let mut current = String::new(); - for line in replaced.lines() { - if line.starts_with("diff --git ") && !current.is_empty() { - blocks.push(current); - current = String::new(); - } - if !current.is_empty() { - current.push('\n'); - } - current.push_str(line); - } - if !current.is_empty() { - blocks.push(current); - } - blocks.sort(); - let mut out = blocks.join("\n"); - if !out.ends_with('\n') { - out.push('\n'); - } - out - } - - #[test] - fn accumulates_add_and_update() { - let mut acc = TurnDiffTracker::new(); - - let dir = tempdir().unwrap(); - let file = dir.path().join("a.txt"); - - // First patch: add file (baseline should be /dev/null). - let add_changes = HashMap::from([( - file.clone(), - FileChange::Add { - content: "foo\n".to_string(), - }, - )]); - acc.on_patch_begin(&add_changes); - - // Simulate apply: create the file on disk. - fs::write(&file, "foo\n").unwrap(); - let first = acc.get_unified_diff().unwrap().unwrap(); - let first = normalize_diff_for_test(&first, dir.path()); - let expected_first = { - let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular); - let right_oid = git_blob_sha1_hex("foo\n"); - format!( - r#"diff --git a//a.txt b//a.txt -new file mode {mode} -index {ZERO_OID}..{right_oid} ---- {DEV_NULL} -+++ b//a.txt -@@ -0,0 +1 @@ -+foo -"#, - ) - }; - assert_eq!(first, expected_first); - - // Second patch: update the file on disk. - let update_changes = HashMap::from([( - file.clone(), - FileChange::Update { - unified_diff: "".to_owned(), - move_path: None, - }, - )]); - acc.on_patch_begin(&update_changes); - - // Simulate apply: append a new line. - fs::write(&file, "foo\nbar\n").unwrap(); - let combined = acc.get_unified_diff().unwrap().unwrap(); - let combined = normalize_diff_for_test(&combined, dir.path()); - let expected_combined = { - let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular); - let right_oid = git_blob_sha1_hex("foo\nbar\n"); - format!( - r#"diff --git a//a.txt b//a.txt -new file mode {mode} -index {ZERO_OID}..{right_oid} ---- {DEV_NULL} -+++ b//a.txt -@@ -0,0 +1,2 @@ -+foo -+bar -"#, - ) - }; - assert_eq!(combined, expected_combined); - } - - #[test] - fn accumulates_delete() { - let dir = tempdir().unwrap(); - let file = dir.path().join("b.txt"); - fs::write(&file, "x\n").unwrap(); - - let mut acc = TurnDiffTracker::new(); - let del_changes = HashMap::from([( - file.clone(), - FileChange::Delete { - content: "x\n".to_string(), - }, - )]); - acc.on_patch_begin(&del_changes); - - // Simulate apply: delete the file from disk. - let baseline_mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular); - fs::remove_file(&file).unwrap(); - let diff = acc.get_unified_diff().unwrap().unwrap(); - let diff = normalize_diff_for_test(&diff, dir.path()); - let expected = { - let left_oid = git_blob_sha1_hex("x\n"); - format!( - r#"diff --git a//b.txt b//b.txt -deleted file mode {baseline_mode} -index {left_oid}..{ZERO_OID} ---- a//b.txt -+++ {DEV_NULL} -@@ -1 +0,0 @@ --x -"#, - ) - }; - assert_eq!(diff, expected); - } - - #[test] - fn accumulates_move_and_update() { - let dir = tempdir().unwrap(); - let src = dir.path().join("src.txt"); - let dest = dir.path().join("dst.txt"); - fs::write(&src, "line\n").unwrap(); - - let mut acc = TurnDiffTracker::new(); - let mv_changes = HashMap::from([( - src.clone(), - FileChange::Update { - unified_diff: "".to_owned(), - move_path: Some(dest.clone()), - }, - )]); - acc.on_patch_begin(&mv_changes); - - // Simulate apply: move and update content. - fs::rename(&src, &dest).unwrap(); - fs::write(&dest, "line2\n").unwrap(); - - let out = acc.get_unified_diff().unwrap().unwrap(); - let out = normalize_diff_for_test(&out, dir.path()); - let expected = { - let left_oid = git_blob_sha1_hex("line\n"); - let right_oid = git_blob_sha1_hex("line2\n"); - format!( - r#"diff --git a//src.txt b//dst.txt -index {left_oid}..{right_oid} ---- a//src.txt -+++ b//dst.txt -@@ -1 +1 @@ --line -+line2 -"# - ) - }; - assert_eq!(out, expected); - } - - #[test] - fn move_without_1change_yields_no_diff() { - let dir = tempdir().unwrap(); - let src = dir.path().join("moved.txt"); - let dest = dir.path().join("renamed.txt"); - fs::write(&src, "same\n").unwrap(); - - let mut acc = TurnDiffTracker::new(); - let mv_changes = HashMap::from([( - src.clone(), - FileChange::Update { - unified_diff: "".to_owned(), - move_path: Some(dest.clone()), - }, - )]); - acc.on_patch_begin(&mv_changes); - - // Simulate apply: move only, no content change. - fs::rename(&src, &dest).unwrap(); - - let diff = acc.get_unified_diff().unwrap(); - assert_eq!(diff, None); - } - - #[test] - fn move_declared_but_file_only_appears_at_dest_is_add() { - let dir = tempdir().unwrap(); - let src = dir.path().join("src.txt"); - let dest = dir.path().join("dest.txt"); - let mut acc = TurnDiffTracker::new(); - let mv = HashMap::from([( - src, - FileChange::Update { - unified_diff: "".into(), - move_path: Some(dest.clone()), - }, - )]); - acc.on_patch_begin(&mv); - // No file existed initially; create only dest - fs::write(&dest, "hello\n").unwrap(); - let diff = acc.get_unified_diff().unwrap().unwrap(); - let diff = normalize_diff_for_test(&diff, dir.path()); - let expected = { - let mode = file_mode_for_path(&dest).unwrap_or(FileMode::Regular); - let right_oid = git_blob_sha1_hex("hello\n"); - format!( - r#"diff --git a//src.txt b//dest.txt -new file mode {mode} -index {ZERO_OID}..{right_oid} ---- {DEV_NULL} -+++ b//dest.txt -@@ -0,0 +1 @@ -+hello -"#, - ) - }; - assert_eq!(diff, expected); - } - - #[test] - fn update_persists_across_new_baseline_for_new_file() { - let dir = tempdir().unwrap(); - let a = dir.path().join("a.txt"); - let b = dir.path().join("b.txt"); - fs::write(&a, "foo\n").unwrap(); - fs::write(&b, "z\n").unwrap(); - - let mut acc = TurnDiffTracker::new(); - - // First: update existing a.txt (baseline snapshot is created for a). - let update_a = HashMap::from([( - a.clone(), - FileChange::Update { - unified_diff: "".to_owned(), - move_path: None, - }, - )]); - acc.on_patch_begin(&update_a); - // Simulate apply: modify a.txt on disk. - fs::write(&a, "foo\nbar\n").unwrap(); - let first = acc.get_unified_diff().unwrap().unwrap(); - let first = normalize_diff_for_test(&first, dir.path()); - let expected_first = { - let left_oid = git_blob_sha1_hex("foo\n"); - let right_oid = git_blob_sha1_hex("foo\nbar\n"); - format!( - r#"diff --git a//a.txt b//a.txt -index {left_oid}..{right_oid} ---- a//a.txt -+++ b//a.txt -@@ -1 +1,2 @@ - foo -+bar -"# - ) - }; - assert_eq!(first, expected_first); - - // Next: introduce a brand-new path b.txt into baseline snapshots via a delete change. - let del_b = HashMap::from([( - b.clone(), - FileChange::Delete { - content: "z\n".to_string(), - }, - )]); - acc.on_patch_begin(&del_b); - // Simulate apply: delete b.txt. - let baseline_mode = file_mode_for_path(&b).unwrap_or(FileMode::Regular); - fs::remove_file(&b).unwrap(); - - let combined = acc.get_unified_diff().unwrap().unwrap(); - let combined = normalize_diff_for_test(&combined, dir.path()); - let expected = { - let left_oid_a = git_blob_sha1_hex("foo\n"); - let right_oid_a = git_blob_sha1_hex("foo\nbar\n"); - let left_oid_b = git_blob_sha1_hex("z\n"); - format!( - r#"diff --git a//a.txt b//a.txt -index {left_oid_a}..{right_oid_a} ---- a//a.txt -+++ b//a.txt -@@ -1 +1,2 @@ - foo -+bar -diff --git a//b.txt b//b.txt -deleted file mode {baseline_mode} -index {left_oid_b}..{ZERO_OID} ---- a//b.txt -+++ {DEV_NULL} -@@ -1 +0,0 @@ --z -"#, - ) - }; - assert_eq!(combined, expected); - } - - #[test] - fn binary_files_differ_update() { - let dir = tempdir().unwrap(); - let file = dir.path().join("bin.dat"); - - // Initial non-UTF8 bytes - let left_bytes: Vec = vec![0xff, 0xfe, 0xfd, 0x00]; - // Updated non-UTF8 bytes - let right_bytes: Vec = vec![0x01, 0x02, 0x03, 0x00]; - - fs::write(&file, &left_bytes).unwrap(); - - let mut acc = TurnDiffTracker::new(); - let update_changes = HashMap::from([( - file.clone(), - FileChange::Update { - unified_diff: "".to_owned(), - move_path: None, - }, - )]); - acc.on_patch_begin(&update_changes); - - // Apply update on disk - fs::write(&file, &right_bytes).unwrap(); - - let diff = acc.get_unified_diff().unwrap().unwrap(); - let diff = normalize_diff_for_test(&diff, dir.path()); - let expected = { - let left_oid = format!("{:x}", git_blob_sha1_hex_bytes(&left_bytes)); - let right_oid = format!("{:x}", git_blob_sha1_hex_bytes(&right_bytes)); - format!( - r#"diff --git a//bin.dat b//bin.dat -index {left_oid}..{right_oid} ---- a//bin.dat -+++ b//bin.dat -Binary files differ -"# - ) - }; - assert_eq!(diff, expected); - } - - #[test] - fn filenames_with_spaces_add_and_update() { - let mut acc = TurnDiffTracker::new(); - - let dir = tempdir().unwrap(); - let file = dir.path().join("name with spaces.txt"); - - // First patch: add file (baseline should be /dev/null). - let add_changes = HashMap::from([( - file.clone(), - FileChange::Add { - content: "foo\n".to_string(), - }, - )]); - acc.on_patch_begin(&add_changes); - - // Simulate apply: create the file on disk. - fs::write(&file, "foo\n").unwrap(); - let first = acc.get_unified_diff().unwrap().unwrap(); - let first = normalize_diff_for_test(&first, dir.path()); - let expected_first = { - let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular); - let right_oid = git_blob_sha1_hex("foo\n"); - format!( - r#"diff --git a//name with spaces.txt b//name with spaces.txt -new file mode {mode} -index {ZERO_OID}..{right_oid} ---- {DEV_NULL} -+++ b//name with spaces.txt -@@ -0,0 +1 @@ -+foo -"#, - ) - }; - assert_eq!(first, expected_first); - - // Second patch: update the file on disk. - let update_changes = HashMap::from([( - file.clone(), - FileChange::Update { - unified_diff: "".to_owned(), - move_path: None, - }, - )]); - acc.on_patch_begin(&update_changes); - - // Simulate apply: append a new line with a space. - fs::write(&file, "foo\nbar baz\n").unwrap(); - let combined = acc.get_unified_diff().unwrap().unwrap(); - let combined = normalize_diff_for_test(&combined, dir.path()); - let expected_combined = { - let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular); - let right_oid = git_blob_sha1_hex("foo\nbar baz\n"); - format!( - r#"diff --git a//name with spaces.txt b//name with spaces.txt -new file mode {mode} -index {ZERO_OID}..{right_oid} ---- {DEV_NULL} -+++ b//name with spaces.txt -@@ -0,0 +1,2 @@ -+foo -+bar baz -"#, - ) - }; - assert_eq!(combined, expected_combined); - } -} +#[path = "turn_diff_tracker_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/turn_diff_tracker_tests.rs b/codex-rs/core/src/turn_diff_tracker_tests.rs new file mode 100644 index 0000000000..e0ab2dd667 --- /dev/null +++ b/codex-rs/core/src/turn_diff_tracker_tests.rs @@ -0,0 +1,427 @@ +use super::*; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +/// Compute the Git SHA-1 blob object ID for the given content (string). +/// This delegates to the bytes version to avoid UTF-8 lossy conversions here. +fn git_blob_sha1_hex(data: &str) -> String { + format!("{:x}", git_blob_sha1_hex_bytes(data.as_bytes())) +} + +fn normalize_diff_for_test(input: &str, root: &Path) -> String { + let root_str = root.display().to_string().replace('\\', "/"); + let replaced = input.replace(&root_str, ""); + // Split into blocks on lines starting with "diff --git ", sort blocks for determinism, and rejoin + let mut blocks: Vec = Vec::new(); + let mut current = String::new(); + for line in replaced.lines() { + if line.starts_with("diff --git ") && !current.is_empty() { + blocks.push(current); + current = String::new(); + } + if !current.is_empty() { + current.push('\n'); + } + current.push_str(line); + } + if !current.is_empty() { + blocks.push(current); + } + blocks.sort(); + let mut out = blocks.join("\n"); + if !out.ends_with('\n') { + out.push('\n'); + } + out +} + +#[test] +fn accumulates_add_and_update() { + let mut acc = TurnDiffTracker::new(); + + let dir = tempdir().unwrap(); + let file = dir.path().join("a.txt"); + + // First patch: add file (baseline should be /dev/null). + let add_changes = HashMap::from([( + file.clone(), + FileChange::Add { + content: "foo\n".to_string(), + }, + )]); + acc.on_patch_begin(&add_changes); + + // Simulate apply: create the file on disk. + fs::write(&file, "foo\n").unwrap(); + let first = acc.get_unified_diff().unwrap().unwrap(); + let first = normalize_diff_for_test(&first, dir.path()); + let expected_first = { + let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular); + let right_oid = git_blob_sha1_hex("foo\n"); + format!( + r#"diff --git a//a.txt b//a.txt +new file mode {mode} +index {ZERO_OID}..{right_oid} +--- {DEV_NULL} ++++ b//a.txt +@@ -0,0 +1 @@ ++foo +"#, + ) + }; + assert_eq!(first, expected_first); + + // Second patch: update the file on disk. + let update_changes = HashMap::from([( + file.clone(), + FileChange::Update { + unified_diff: "".to_owned(), + move_path: None, + }, + )]); + acc.on_patch_begin(&update_changes); + + // Simulate apply: append a new line. + fs::write(&file, "foo\nbar\n").unwrap(); + let combined = acc.get_unified_diff().unwrap().unwrap(); + let combined = normalize_diff_for_test(&combined, dir.path()); + let expected_combined = { + let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular); + let right_oid = git_blob_sha1_hex("foo\nbar\n"); + format!( + r#"diff --git a//a.txt b//a.txt +new file mode {mode} +index {ZERO_OID}..{right_oid} +--- {DEV_NULL} ++++ b//a.txt +@@ -0,0 +1,2 @@ ++foo ++bar +"#, + ) + }; + assert_eq!(combined, expected_combined); +} + +#[test] +fn accumulates_delete() { + let dir = tempdir().unwrap(); + let file = dir.path().join("b.txt"); + fs::write(&file, "x\n").unwrap(); + + let mut acc = TurnDiffTracker::new(); + let del_changes = HashMap::from([( + file.clone(), + FileChange::Delete { + content: "x\n".to_string(), + }, + )]); + acc.on_patch_begin(&del_changes); + + // Simulate apply: delete the file from disk. + let baseline_mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular); + fs::remove_file(&file).unwrap(); + let diff = acc.get_unified_diff().unwrap().unwrap(); + let diff = normalize_diff_for_test(&diff, dir.path()); + let expected = { + let left_oid = git_blob_sha1_hex("x\n"); + format!( + r#"diff --git a//b.txt b//b.txt +deleted file mode {baseline_mode} +index {left_oid}..{ZERO_OID} +--- a//b.txt ++++ {DEV_NULL} +@@ -1 +0,0 @@ +-x +"#, + ) + }; + assert_eq!(diff, expected); +} + +#[test] +fn accumulates_move_and_update() { + let dir = tempdir().unwrap(); + let src = dir.path().join("src.txt"); + let dest = dir.path().join("dst.txt"); + fs::write(&src, "line\n").unwrap(); + + let mut acc = TurnDiffTracker::new(); + let mv_changes = HashMap::from([( + src.clone(), + FileChange::Update { + unified_diff: "".to_owned(), + move_path: Some(dest.clone()), + }, + )]); + acc.on_patch_begin(&mv_changes); + + // Simulate apply: move and update content. + fs::rename(&src, &dest).unwrap(); + fs::write(&dest, "line2\n").unwrap(); + + let out = acc.get_unified_diff().unwrap().unwrap(); + let out = normalize_diff_for_test(&out, dir.path()); + let expected = { + let left_oid = git_blob_sha1_hex("line\n"); + let right_oid = git_blob_sha1_hex("line2\n"); + format!( + r#"diff --git a//src.txt b//dst.txt +index {left_oid}..{right_oid} +--- a//src.txt ++++ b//dst.txt +@@ -1 +1 @@ +-line ++line2 +"# + ) + }; + assert_eq!(out, expected); +} + +#[test] +fn move_without_1change_yields_no_diff() { + let dir = tempdir().unwrap(); + let src = dir.path().join("moved.txt"); + let dest = dir.path().join("renamed.txt"); + fs::write(&src, "same\n").unwrap(); + + let mut acc = TurnDiffTracker::new(); + let mv_changes = HashMap::from([( + src.clone(), + FileChange::Update { + unified_diff: "".to_owned(), + move_path: Some(dest.clone()), + }, + )]); + acc.on_patch_begin(&mv_changes); + + // Simulate apply: move only, no content change. + fs::rename(&src, &dest).unwrap(); + + let diff = acc.get_unified_diff().unwrap(); + assert_eq!(diff, None); +} + +#[test] +fn move_declared_but_file_only_appears_at_dest_is_add() { + let dir = tempdir().unwrap(); + let src = dir.path().join("src.txt"); + let dest = dir.path().join("dest.txt"); + let mut acc = TurnDiffTracker::new(); + let mv = HashMap::from([( + src, + FileChange::Update { + unified_diff: "".into(), + move_path: Some(dest.clone()), + }, + )]); + acc.on_patch_begin(&mv); + // No file existed initially; create only dest + fs::write(&dest, "hello\n").unwrap(); + let diff = acc.get_unified_diff().unwrap().unwrap(); + let diff = normalize_diff_for_test(&diff, dir.path()); + let expected = { + let mode = file_mode_for_path(&dest).unwrap_or(FileMode::Regular); + let right_oid = git_blob_sha1_hex("hello\n"); + format!( + r#"diff --git a//src.txt b//dest.txt +new file mode {mode} +index {ZERO_OID}..{right_oid} +--- {DEV_NULL} ++++ b//dest.txt +@@ -0,0 +1 @@ ++hello +"#, + ) + }; + assert_eq!(diff, expected); +} + +#[test] +fn update_persists_across_new_baseline_for_new_file() { + let dir = tempdir().unwrap(); + let a = dir.path().join("a.txt"); + let b = dir.path().join("b.txt"); + fs::write(&a, "foo\n").unwrap(); + fs::write(&b, "z\n").unwrap(); + + let mut acc = TurnDiffTracker::new(); + + // First: update existing a.txt (baseline snapshot is created for a). + let update_a = HashMap::from([( + a.clone(), + FileChange::Update { + unified_diff: "".to_owned(), + move_path: None, + }, + )]); + acc.on_patch_begin(&update_a); + // Simulate apply: modify a.txt on disk. + fs::write(&a, "foo\nbar\n").unwrap(); + let first = acc.get_unified_diff().unwrap().unwrap(); + let first = normalize_diff_for_test(&first, dir.path()); + let expected_first = { + let left_oid = git_blob_sha1_hex("foo\n"); + let right_oid = git_blob_sha1_hex("foo\nbar\n"); + format!( + r#"diff --git a//a.txt b//a.txt +index {left_oid}..{right_oid} +--- a//a.txt ++++ b//a.txt +@@ -1 +1,2 @@ + foo ++bar +"# + ) + }; + assert_eq!(first, expected_first); + + // Next: introduce a brand-new path b.txt into baseline snapshots via a delete change. + let del_b = HashMap::from([( + b.clone(), + FileChange::Delete { + content: "z\n".to_string(), + }, + )]); + acc.on_patch_begin(&del_b); + // Simulate apply: delete b.txt. + let baseline_mode = file_mode_for_path(&b).unwrap_or(FileMode::Regular); + fs::remove_file(&b).unwrap(); + + let combined = acc.get_unified_diff().unwrap().unwrap(); + let combined = normalize_diff_for_test(&combined, dir.path()); + let expected = { + let left_oid_a = git_blob_sha1_hex("foo\n"); + let right_oid_a = git_blob_sha1_hex("foo\nbar\n"); + let left_oid_b = git_blob_sha1_hex("z\n"); + format!( + r#"diff --git a//a.txt b//a.txt +index {left_oid_a}..{right_oid_a} +--- a//a.txt ++++ b//a.txt +@@ -1 +1,2 @@ + foo ++bar +diff --git a//b.txt b//b.txt +deleted file mode {baseline_mode} +index {left_oid_b}..{ZERO_OID} +--- a//b.txt ++++ {DEV_NULL} +@@ -1 +0,0 @@ +-z +"#, + ) + }; + assert_eq!(combined, expected); +} + +#[test] +fn binary_files_differ_update() { + let dir = tempdir().unwrap(); + let file = dir.path().join("bin.dat"); + + // Initial non-UTF8 bytes + let left_bytes: Vec = vec![0xff, 0xfe, 0xfd, 0x00]; + // Updated non-UTF8 bytes + let right_bytes: Vec = vec![0x01, 0x02, 0x03, 0x00]; + + fs::write(&file, &left_bytes).unwrap(); + + let mut acc = TurnDiffTracker::new(); + let update_changes = HashMap::from([( + file.clone(), + FileChange::Update { + unified_diff: "".to_owned(), + move_path: None, + }, + )]); + acc.on_patch_begin(&update_changes); + + // Apply update on disk + fs::write(&file, &right_bytes).unwrap(); + + let diff = acc.get_unified_diff().unwrap().unwrap(); + let diff = normalize_diff_for_test(&diff, dir.path()); + let expected = { + let left_oid = format!("{:x}", git_blob_sha1_hex_bytes(&left_bytes)); + let right_oid = format!("{:x}", git_blob_sha1_hex_bytes(&right_bytes)); + format!( + r#"diff --git a//bin.dat b//bin.dat +index {left_oid}..{right_oid} +--- a//bin.dat ++++ b//bin.dat +Binary files differ +"# + ) + }; + assert_eq!(diff, expected); +} + +#[test] +fn filenames_with_spaces_add_and_update() { + let mut acc = TurnDiffTracker::new(); + + let dir = tempdir().unwrap(); + let file = dir.path().join("name with spaces.txt"); + + // First patch: add file (baseline should be /dev/null). + let add_changes = HashMap::from([( + file.clone(), + FileChange::Add { + content: "foo\n".to_string(), + }, + )]); + acc.on_patch_begin(&add_changes); + + // Simulate apply: create the file on disk. + fs::write(&file, "foo\n").unwrap(); + let first = acc.get_unified_diff().unwrap().unwrap(); + let first = normalize_diff_for_test(&first, dir.path()); + let expected_first = { + let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular); + let right_oid = git_blob_sha1_hex("foo\n"); + format!( + r#"diff --git a//name with spaces.txt b//name with spaces.txt +new file mode {mode} +index {ZERO_OID}..{right_oid} +--- {DEV_NULL} ++++ b//name with spaces.txt +@@ -0,0 +1 @@ ++foo +"#, + ) + }; + assert_eq!(first, expected_first); + + // Second patch: update the file on disk. + let update_changes = HashMap::from([( + file.clone(), + FileChange::Update { + unified_diff: "".to_owned(), + move_path: None, + }, + )]); + acc.on_patch_begin(&update_changes); + + // Simulate apply: append a new line with a space. + fs::write(&file, "foo\nbar baz\n").unwrap(); + let combined = acc.get_unified_diff().unwrap().unwrap(); + let combined = normalize_diff_for_test(&combined, dir.path()); + let expected_combined = { + let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular); + let right_oid = git_blob_sha1_hex("foo\nbar baz\n"); + format!( + r#"diff --git a//name with spaces.txt b//name with spaces.txt +new file mode {mode} +index {ZERO_OID}..{right_oid} +--- {DEV_NULL} ++++ b//name with spaces.txt +@@ -0,0 +1,2 @@ ++foo ++bar baz +"#, + ) + }; + assert_eq!(combined, expected_combined); +} diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 0814897d91..44d36b3a14 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -132,17 +132,9 @@ impl TurnMetadataState { cwd: PathBuf, sandbox_policy: &SandboxPolicy, windows_sandbox_level: WindowsSandboxLevel, - use_linux_sandbox_bwrap: bool, ) -> Self { let repo_root = get_git_repo_root(&cwd).map(|root| root.to_string_lossy().into_owned()); - let sandbox = Some( - sandbox_tag( - sandbox_policy, - windows_sandbox_level, - use_linux_sandbox_bwrap, - ) - .to_string(), - ); + let sandbox = Some(sandbox_tag(sandbox_policy, windows_sandbox_level).to_string()); let base_metadata = build_turn_metadata_bag(Some(turn_id), sandbox, None, None); let base_header = base_metadata .to_header_value() @@ -236,114 +228,5 @@ impl TurnMetadataState { } #[cfg(test)] -mod tests { - use super::*; - - use serde_json::Value; - use tempfile::TempDir; - use tokio::process::Command; - - #[tokio::test] - async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { - let temp_dir = TempDir::new().expect("temp dir"); - let repo_path = temp_dir.path().join("repo"); - std::fs::create_dir_all(&repo_path).expect("create repo"); - - Command::new("git") - .args(["init"]) - .current_dir(&repo_path) - .output() - .await - .expect("git init"); - Command::new("git") - .args(["config", "user.name", "Test User"]) - .current_dir(&repo_path) - .output() - .await - .expect("git config user.name"); - Command::new("git") - .args(["config", "user.email", "test@example.com"]) - .current_dir(&repo_path) - .output() - .await - .expect("git config user.email"); - - std::fs::write(repo_path.join("README.md"), "hello").expect("write file"); - Command::new("git") - .args(["add", "."]) - .current_dir(&repo_path) - .output() - .await - .expect("git add"); - Command::new("git") - .args(["commit", "-m", "initial"]) - .current_dir(&repo_path) - .output() - .await - .expect("git commit"); - - let header = build_turn_metadata_header(&repo_path, Some("none")) - .await - .expect("header"); - let parsed: Value = serde_json::from_str(&header).expect("valid json"); - let workspace = parsed - .get("workspaces") - .and_then(Value::as_object) - .and_then(|workspaces| workspaces.values().next()) - .cloned() - .expect("workspace"); - - assert_eq!( - workspace.get("has_changes").and_then(Value::as_bool), - Some(false) - ); - } - - #[test] - fn turn_metadata_state_respects_linux_bubblewrap_toggle() { - let temp_dir = TempDir::new().expect("temp dir"); - let cwd = temp_dir.path().to_path_buf(); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); - - let without_bubblewrap = TurnMetadataState::new( - "turn-a".to_string(), - cwd.clone(), - &sandbox_policy, - WindowsSandboxLevel::Disabled, - false, - ); - let with_bubblewrap = TurnMetadataState::new( - "turn-b".to_string(), - cwd, - &sandbox_policy, - WindowsSandboxLevel::Disabled, - true, - ); - - let without_bubblewrap_header = without_bubblewrap - .current_header_value() - .expect("without_bubblewrap_header"); - let with_bubblewrap_header = with_bubblewrap - .current_header_value() - .expect("with_bubblewrap_header"); - - let without_bubblewrap_json: Value = - serde_json::from_str(&without_bubblewrap_header).expect("without_bubblewrap_json"); - let with_bubblewrap_json: Value = - serde_json::from_str(&with_bubblewrap_header).expect("with_bubblewrap_json"); - - let without_bubblewrap_sandbox = without_bubblewrap_json - .get("sandbox") - .and_then(Value::as_str); - let with_bubblewrap_sandbox = with_bubblewrap_json.get("sandbox").and_then(Value::as_str); - - let expected_with_bubblewrap = - sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled, true); - assert_eq!(with_bubblewrap_sandbox, Some(expected_with_bubblewrap)); - - if cfg!(target_os = "linux") { - assert_eq!(with_bubblewrap_sandbox, Some("linux_bubblewrap")); - assert_ne!(with_bubblewrap_sandbox, without_bubblewrap_sandbox); - } - } -} +#[path = "turn_metadata_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs new file mode 100644 index 0000000000..5124213de3 --- /dev/null +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -0,0 +1,82 @@ +use super::*; + +use serde_json::Value; +use tempfile::TempDir; +use tokio::process::Command; + +#[tokio::test] +async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { + let temp_dir = TempDir::new().expect("temp dir"); + let repo_path = temp_dir.path().join("repo"); + std::fs::create_dir_all(&repo_path).expect("create repo"); + + Command::new("git") + .args(["init"]) + .current_dir(&repo_path) + .output() + .await + .expect("git init"); + Command::new("git") + .args(["config", "user.name", "Test User"]) + .current_dir(&repo_path) + .output() + .await + .expect("git config user.name"); + Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(&repo_path) + .output() + .await + .expect("git config user.email"); + + std::fs::write(repo_path.join("README.md"), "hello").expect("write file"); + Command::new("git") + .args(["add", "."]) + .current_dir(&repo_path) + .output() + .await + .expect("git add"); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(&repo_path) + .output() + .await + .expect("git commit"); + + let header = build_turn_metadata_header(&repo_path, Some("none")) + .await + .expect("header"); + let parsed: Value = serde_json::from_str(&header).expect("valid json"); + let workspace = parsed + .get("workspaces") + .and_then(Value::as_object) + .and_then(|workspaces| workspaces.values().next()) + .cloned() + .expect("workspace"); + + assert_eq!( + workspace.get("has_changes").and_then(Value::as_bool), + Some(false) + ); +} + +#[test] +fn turn_metadata_state_uses_platform_sandbox_tag() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().to_path_buf(); + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + + let state = TurnMetadataState::new( + "turn-a".to_string(), + cwd, + &sandbox_policy, + WindowsSandboxLevel::Disabled, + ); + + let header = state.current_header_value().expect("header"); + let json: Value = serde_json::from_str(&header).expect("json"); + let sandbox_name = json.get("sandbox").and_then(Value::as_str); + + let expected_sandbox = sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled); + assert_eq!(sandbox_name, Some(expected_sandbox)); +} diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 8004682894..c68f16e451 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -141,146 +141,18 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::CustomToolCall { .. } + | ResponseItem::ToolSearchCall { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::GhostSnapshot { .. } | ResponseItem::Compaction { .. } => true, ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } | ResponseItem::Other => false, } } #[cfg(test)] -mod tests { - use codex_protocol::items::AgentMessageItem; - use codex_protocol::items::TurnItem; - use codex_protocol::models::ContentItem; - use codex_protocol::models::FunctionCallOutputPayload; - use codex_protocol::models::ResponseItem; - use pretty_assertions::assert_eq; - use std::time::Instant; - - use super::TurnTimingState; - use super::response_item_records_turn_ttft; - use crate::ResponseEvent; - - #[tokio::test] - async fn turn_timing_state_records_ttft_only_once_per_turn() { - let state = TurnTimingState::default(); - assert_eq!( - state - .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta("hi".to_string())) - .await, - None - ); - - state.mark_turn_started(Instant::now()).await; - assert_eq!( - state - .record_ttft_for_response_event(&ResponseEvent::Created) - .await, - None - ); - assert!( - state - .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta("hi".to_string())) - .await - .is_some() - ); - assert_eq!( - state - .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta( - "again".to_string() - )) - .await, - None - ); - } - - #[tokio::test] - async fn turn_timing_state_records_ttfm_independently_of_ttft() { - let state = TurnTimingState::default(); - state.mark_turn_started(Instant::now()).await; - - assert!( - state - .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta("hi".to_string())) - .await - .is_some() - ); - assert!( - state - .record_ttfm_for_turn_item(&TurnItem::AgentMessage(AgentMessageItem { - id: "msg-1".to_string(), - content: Vec::new(), - phase: None, - metadata: None, - })) - .await - .is_some() - ); - assert_eq!( - state - .record_ttfm_for_turn_item(&TurnItem::AgentMessage(AgentMessageItem { - id: "msg-2".to_string(), - content: Vec::new(), - phase: None, - metadata: None, - })) - .await, - None - ); - } - - #[test] - fn response_item_records_turn_ttft_for_first_output_signals() { - assert!(response_item_records_turn_ttft( - &ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - arguments: "{}".to_string(), - call_id: "call-1".to_string(), - } - )); - assert!(response_item_records_turn_ttft( - &ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "call-2".to_string(), - name: "custom".to_string(), - input: "echo hi".to_string(), - } - )); - assert!(response_item_records_turn_ttft(&ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "hello".to_string(), - }], - metadata: None, - end_turn: None, - phase: None, - })); - } - - #[test] - fn response_item_records_turn_ttft_ignores_empty_non_output_items() { - assert!(!response_item_records_turn_ttft(&ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: String::new(), - }], - metadata: None, - end_turn: None, - phase: None, - })); - assert!(!response_item_records_turn_ttft( - &ResponseItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload::from_text("ok".to_string()), - } - )); - } -} +#[path = "turn_timing_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs new file mode 100644 index 0000000000..162e456bcd --- /dev/null +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -0,0 +1,129 @@ +use codex_protocol::items::AgentMessageItem; +use codex_protocol::items::TurnItem; +use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; +use std::time::Instant; + +use super::TurnTimingState; +use super::response_item_records_turn_ttft; +use crate::ResponseEvent; + +#[tokio::test] +async fn turn_timing_state_records_ttft_only_once_per_turn() { + let state = TurnTimingState::default(); + assert_eq!( + state + .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta("hi".to_string())) + .await, + None + ); + + state.mark_turn_started(Instant::now()).await; + assert_eq!( + state + .record_ttft_for_response_event(&ResponseEvent::Created) + .await, + None + ); + assert!( + state + .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta("hi".to_string())) + .await + .is_some() + ); + assert_eq!( + state + .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta("again".to_string())) + .await, + None + ); +} + +#[tokio::test] +async fn turn_timing_state_records_ttfm_independently_of_ttft() { + let state = TurnTimingState::default(); + state.mark_turn_started(Instant::now()).await; + + assert!( + state + .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta("hi".to_string())) + .await + .is_some() + ); + assert!( + state + .record_ttfm_for_turn_item(&TurnItem::AgentMessage(AgentMessageItem { + id: "msg-1".to_string(), + content: Vec::new(), + phase: None, + metadata: None, + })) + .await + .is_some() + ); + assert_eq!( + state + .record_ttfm_for_turn_item(&TurnItem::AgentMessage(AgentMessageItem { + id: "msg-2".to_string(), + content: Vec::new(), + phase: None, + metadata: None, + })) + .await, + None + ); +} + +#[test] +fn response_item_records_turn_ttft_for_first_output_signals() { + assert!(response_item_records_turn_ttft( + &ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + } + )); + assert!(response_item_records_turn_ttft( + &ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-2".to_string(), + name: "custom".to_string(), + input: "echo hi".to_string(), + } + )); + assert!(response_item_records_turn_ttft(&ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "hello".to_string(), + }], + metadata: None, + end_turn: None, + phase: None, + })); +} + +#[test] +fn response_item_records_turn_ttft_ignores_empty_non_output_items() { + assert!(!response_item_records_turn_ttft(&ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: String::new(), + }], + metadata: None, + end_turn: None, + phase: None, + })); + assert!(!response_item_records_turn_ttft( + &ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text("ok".to_string()), + } + )); +} diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 47543a00fc..d595342544 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -251,40 +251,5 @@ async fn resolve_aggregated_output( } #[cfg(test)] -mod tests { - use super::split_valid_utf8_prefix_with_max; - - use pretty_assertions::assert_eq; - - #[test] - fn split_valid_utf8_prefix_respects_max_bytes_for_ascii() { - let mut buf = b"hello word!".to_vec(); - - let first = split_valid_utf8_prefix_with_max(&mut buf, 5).expect("expected prefix"); - assert_eq!(first, b"hello".to_vec()); - assert_eq!(buf, b" word!".to_vec()); - - let second = split_valid_utf8_prefix_with_max(&mut buf, 5).expect("expected prefix"); - assert_eq!(second, b" word".to_vec()); - assert_eq!(buf, b"!".to_vec()); - } - - #[test] - fn split_valid_utf8_prefix_avoids_splitting_utf8_codepoints() { - // "é" is 2 bytes in UTF-8. With a max of 3 bytes, we should only emit 1 char (2 bytes). - let mut buf = "ééé".as_bytes().to_vec(); - - let first = split_valid_utf8_prefix_with_max(&mut buf, 3).expect("expected prefix"); - assert_eq!(std::str::from_utf8(&first).unwrap(), "é"); - assert_eq!(buf, "éé".as_bytes().to_vec()); - } - - #[test] - fn split_valid_utf8_prefix_makes_progress_on_invalid_utf8() { - let mut buf = vec![0xff, b'a', b'b']; - - let first = split_valid_utf8_prefix_with_max(&mut buf, 2).expect("expected prefix"); - assert_eq!(first, vec![0xff]); - assert_eq!(buf, b"ab".to_vec()); - } -} +#[path = "async_watcher_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/unified_exec/async_watcher_tests.rs b/codex-rs/core/src/unified_exec/async_watcher_tests.rs new file mode 100644 index 0000000000..bdf8f7534b --- /dev/null +++ b/codex-rs/core/src/unified_exec/async_watcher_tests.rs @@ -0,0 +1,35 @@ +use super::split_valid_utf8_prefix_with_max; + +use pretty_assertions::assert_eq; + +#[test] +fn split_valid_utf8_prefix_respects_max_bytes_for_ascii() { + let mut buf = b"hello word!".to_vec(); + + let first = split_valid_utf8_prefix_with_max(&mut buf, 5).expect("expected prefix"); + assert_eq!(first, b"hello".to_vec()); + assert_eq!(buf, b" word!".to_vec()); + + let second = split_valid_utf8_prefix_with_max(&mut buf, 5).expect("expected prefix"); + assert_eq!(second, b" word".to_vec()); + assert_eq!(buf, b"!".to_vec()); +} + +#[test] +fn split_valid_utf8_prefix_avoids_splitting_utf8_codepoints() { + // "é" is 2 bytes in UTF-8. With a max of 3 bytes, we should only emit 1 char (2 bytes). + let mut buf = "ééé".as_bytes().to_vec(); + + let first = split_valid_utf8_prefix_with_max(&mut buf, 3).expect("expected prefix"); + assert_eq!(std::str::from_utf8(&first).unwrap(), "é"); + assert_eq!(buf, "éé".as_bytes().to_vec()); +} + +#[test] +fn split_valid_utf8_prefix_makes_progress_on_invalid_utf8() { + let mut buf = vec![0xff, b'a', b'b']; + + let first = split_valid_utf8_prefix_with_max(&mut buf, 2).expect("expected prefix"); + assert_eq!(first, vec![0xff]); + assert_eq!(buf, b"ab".to_vec()); +} diff --git a/codex-rs/core/src/unified_exec/head_tail_buffer.rs b/codex-rs/core/src/unified_exec/head_tail_buffer.rs index 8524466048..52039e1495 100644 --- a/codex-rs/core/src/unified_exec/head_tail_buffer.rs +++ b/codex-rs/core/src/unified_exec/head_tail_buffer.rs @@ -179,94 +179,5 @@ impl HeadTailBuffer { } #[cfg(test)] -mod tests { - use super::HeadTailBuffer; - - use pretty_assertions::assert_eq; - - #[test] - fn keeps_prefix_and_suffix_when_over_budget() { - let mut buf = HeadTailBuffer::new(10); - - buf.push_chunk(b"0123456789".to_vec()); - assert_eq!(buf.omitted_bytes(), 0); - - // Exceeds max by 2; we should keep head+tail and omit the middle. - buf.push_chunk(b"ab".to_vec()); - assert!(buf.omitted_bytes() > 0); - - let rendered = String::from_utf8_lossy(&buf.to_bytes()).to_string(); - assert!(rendered.starts_with("01234")); - assert!(rendered.ends_with("89ab")); - } - - #[test] - fn max_bytes_zero_drops_everything() { - let mut buf = HeadTailBuffer::new(0); - buf.push_chunk(b"abc".to_vec()); - - assert_eq!(buf.retained_bytes(), 0); - assert_eq!(buf.omitted_bytes(), 3); - assert_eq!(buf.to_bytes(), b"".to_vec()); - assert_eq!(buf.snapshot_chunks(), Vec::>::new()); - } - - #[test] - fn head_budget_zero_keeps_only_last_byte_in_tail() { - let mut buf = HeadTailBuffer::new(1); - buf.push_chunk(b"abc".to_vec()); - - assert_eq!(buf.retained_bytes(), 1); - assert_eq!(buf.omitted_bytes(), 2); - assert_eq!(buf.to_bytes(), b"c".to_vec()); - } - - #[test] - fn draining_resets_state() { - let mut buf = HeadTailBuffer::new(10); - buf.push_chunk(b"0123456789".to_vec()); - buf.push_chunk(b"ab".to_vec()); - - let drained = buf.drain_chunks(); - assert!(!drained.is_empty()); - - assert_eq!(buf.retained_bytes(), 0); - assert_eq!(buf.omitted_bytes(), 0); - assert_eq!(buf.to_bytes(), b"".to_vec()); - } - - #[test] - fn chunk_larger_than_tail_budget_keeps_only_tail_end() { - let mut buf = HeadTailBuffer::new(10); - buf.push_chunk(b"0123456789".to_vec()); - - // Tail budget is 5 bytes. This chunk should replace the tail and keep only its last 5 bytes. - buf.push_chunk(b"ABCDEFGHIJK".to_vec()); - - let out = String::from_utf8_lossy(&buf.to_bytes()).to_string(); - assert!(out.starts_with("01234")); - assert!(out.ends_with("GHIJK")); - assert!(buf.omitted_bytes() > 0); - } - - #[test] - fn fills_head_then_tail_across_multiple_chunks() { - let mut buf = HeadTailBuffer::new(10); - - // Fill the 5-byte head budget across multiple chunks. - buf.push_chunk(b"01".to_vec()); - buf.push_chunk(b"234".to_vec()); - assert_eq!(buf.to_bytes(), b"01234".to_vec()); - - // Then fill the 5-byte tail budget. - buf.push_chunk(b"567".to_vec()); - buf.push_chunk(b"89".to_vec()); - assert_eq!(buf.to_bytes(), b"0123456789".to_vec()); - assert_eq!(buf.omitted_bytes(), 0); - - // One more byte causes the tail to drop its oldest byte. - buf.push_chunk(b"a".to_vec()); - assert_eq!(buf.to_bytes(), b"012346789a".to_vec()); - assert_eq!(buf.omitted_bytes(), 1); - } -} +#[path = "head_tail_buffer_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/unified_exec/head_tail_buffer_tests.rs b/codex-rs/core/src/unified_exec/head_tail_buffer_tests.rs new file mode 100644 index 0000000000..55493a6b84 --- /dev/null +++ b/codex-rs/core/src/unified_exec/head_tail_buffer_tests.rs @@ -0,0 +1,89 @@ +use super::HeadTailBuffer; + +use pretty_assertions::assert_eq; + +#[test] +fn keeps_prefix_and_suffix_when_over_budget() { + let mut buf = HeadTailBuffer::new(10); + + buf.push_chunk(b"0123456789".to_vec()); + assert_eq!(buf.omitted_bytes(), 0); + + // Exceeds max by 2; we should keep head+tail and omit the middle. + buf.push_chunk(b"ab".to_vec()); + assert!(buf.omitted_bytes() > 0); + + let rendered = String::from_utf8_lossy(&buf.to_bytes()).to_string(); + assert!(rendered.starts_with("01234")); + assert!(rendered.ends_with("89ab")); +} + +#[test] +fn max_bytes_zero_drops_everything() { + let mut buf = HeadTailBuffer::new(0); + buf.push_chunk(b"abc".to_vec()); + + assert_eq!(buf.retained_bytes(), 0); + assert_eq!(buf.omitted_bytes(), 3); + assert_eq!(buf.to_bytes(), b"".to_vec()); + assert_eq!(buf.snapshot_chunks(), Vec::>::new()); +} + +#[test] +fn head_budget_zero_keeps_only_last_byte_in_tail() { + let mut buf = HeadTailBuffer::new(1); + buf.push_chunk(b"abc".to_vec()); + + assert_eq!(buf.retained_bytes(), 1); + assert_eq!(buf.omitted_bytes(), 2); + assert_eq!(buf.to_bytes(), b"c".to_vec()); +} + +#[test] +fn draining_resets_state() { + let mut buf = HeadTailBuffer::new(10); + buf.push_chunk(b"0123456789".to_vec()); + buf.push_chunk(b"ab".to_vec()); + + let drained = buf.drain_chunks(); + assert!(!drained.is_empty()); + + assert_eq!(buf.retained_bytes(), 0); + assert_eq!(buf.omitted_bytes(), 0); + assert_eq!(buf.to_bytes(), b"".to_vec()); +} + +#[test] +fn chunk_larger_than_tail_budget_keeps_only_tail_end() { + let mut buf = HeadTailBuffer::new(10); + buf.push_chunk(b"0123456789".to_vec()); + + // Tail budget is 5 bytes. This chunk should replace the tail and keep only its last 5 bytes. + buf.push_chunk(b"ABCDEFGHIJK".to_vec()); + + let out = String::from_utf8_lossy(&buf.to_bytes()).to_string(); + assert!(out.starts_with("01234")); + assert!(out.ends_with("GHIJK")); + assert!(buf.omitted_bytes() > 0); +} + +#[test] +fn fills_head_then_tail_across_multiple_chunks() { + let mut buf = HeadTailBuffer::new(10); + + // Fill the 5-byte head budget across multiple chunks. + buf.push_chunk(b"01".to_vec()); + buf.push_chunk(b"234".to_vec()); + assert_eq!(buf.to_bytes(), b"01234".to_vec()); + + // Then fill the 5-byte tail budget. + buf.push_chunk(b"567".to_vec()); + buf.push_chunk(b"89".to_vec()); + assert_eq!(buf.to_bytes(), b"0123456789".to_vec()); + assert_eq!(buf.omitted_bytes(), 0); + + // One more byte causes the tail to drop its oldest byte. + buf.push_chunk(b"a".to_vec()); + assert_eq!(buf.to_bytes(), b"012346789a".to_vec()); + assert_eq!(buf.omitted_bytes(), 1); +} diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 91af47accd..3e69a71eea 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -169,350 +169,5 @@ pub(crate) fn generate_chunk_id() -> String { #[cfg(test)] #[cfg(unix)] -mod tests { - use super::head_tail_buffer::HeadTailBuffer; - use super::*; - use crate::codex::Session; - use crate::codex::TurnContext; - use crate::codex::make_session_and_context; - use crate::protocol::AskForApproval; - use crate::protocol::SandboxPolicy; - use crate::tools::context::ExecCommandToolOutput; - use crate::unified_exec::ExecCommandRequest; - use crate::unified_exec::WriteStdinRequest; - use core_test_support::skip_if_sandbox; - use std::sync::Arc; - use tokio::time::Duration; - - async fn test_session_and_turn() -> (Arc, Arc) { - let (session, mut turn) = make_session_and_context().await; - turn.approval_policy - .set(AskForApproval::Never) - .expect("test setup should allow updating approval policy"); - turn.sandbox_policy - .set(SandboxPolicy::DangerFullAccess) - .expect("test setup should allow updating sandbox policy"); - turn.file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from(turn.sandbox_policy.get()); - turn.network_sandbox_policy = - codex_protocol::permissions::NetworkSandboxPolicy::from(turn.sandbox_policy.get()); - (Arc::new(session), Arc::new(turn)) - } - - async fn exec_command( - session: &Arc, - turn: &Arc, - cmd: &str, - yield_time_ms: u64, - ) -> Result { - let context = - UnifiedExecContext::new(Arc::clone(session), Arc::clone(turn), "call".to_string()); - let process_id = session - .services - .unified_exec_manager - .allocate_process_id() - .await; - - session - .services - .unified_exec_manager - .exec_command( - ExecCommandRequest { - command: vec!["bash".to_string(), "-lc".to_string(), cmd.to_string()], - process_id, - yield_time_ms, - max_output_tokens: None, - workdir: None, - network: None, - tty: true, - sandbox_permissions: SandboxPermissions::UseDefault, - additional_permissions: None, - additional_permissions_preapproved: false, - justification: None, - prefix_rule: None, - }, - &context, - ) - .await - } - - async fn write_stdin( - session: &Arc, - process_id: i32, - input: &str, - yield_time_ms: u64, - ) -> Result { - session - .services - .unified_exec_manager - .write_stdin(WriteStdinRequest { - process_id, - input, - yield_time_ms, - max_output_tokens: None, - }) - .await - } - - #[test] - fn push_chunk_preserves_prefix_and_suffix() { - let mut buffer = HeadTailBuffer::default(); - buffer.push_chunk(vec![b'a'; UNIFIED_EXEC_OUTPUT_MAX_BYTES]); - buffer.push_chunk(vec![b'b']); - buffer.push_chunk(vec![b'c']); - - assert_eq!(buffer.retained_bytes(), UNIFIED_EXEC_OUTPUT_MAX_BYTES); - let snapshot = buffer.snapshot_chunks(); - - let first = snapshot.first().expect("expected at least one chunk"); - assert_eq!(first.first(), Some(&b'a')); - assert!(snapshot.iter().any(|chunk| chunk.as_slice() == b"b")); - assert_eq!( - snapshot - .last() - .expect("expected at least one chunk") - .as_slice(), - b"c" - ); - } - - #[test] - fn head_tail_buffer_default_preserves_prefix_and_suffix() { - let mut buffer = HeadTailBuffer::default(); - buffer.push_chunk(vec![b'a'; UNIFIED_EXEC_OUTPUT_MAX_BYTES]); - buffer.push_chunk(b"bc".to_vec()); - - let rendered = buffer.to_bytes(); - assert_eq!(rendered.first(), Some(&b'a')); - assert!(rendered.ends_with(b"bc")); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn unified_exec_persists_across_requests() -> anyhow::Result<()> { - skip_if_sandbox!(Ok(())); - - let (session, turn) = test_session_and_turn().await; - - let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?; - let process_id = open_shell.process_id.expect("expected process_id"); - - write_stdin( - &session, - process_id, - "export CODEX_INTERACTIVE_SHELL_VAR=codex\n", - 2_500, - ) - .await?; - - let out_2 = write_stdin( - &session, - process_id, - "echo $CODEX_INTERACTIVE_SHELL_VAR\n", - 2_500, - ) - .await?; - assert!( - out_2.truncated_output().contains("codex"), - "expected environment variable output" - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn multi_unified_exec_sessions() -> anyhow::Result<()> { - skip_if_sandbox!(Ok(())); - - let (session, turn) = test_session_and_turn().await; - - let shell_a = exec_command(&session, &turn, "bash -i", 2_500).await?; - let session_a = shell_a.process_id.expect("expected process id"); - - write_stdin( - &session, - session_a, - "export CODEX_INTERACTIVE_SHELL_VAR=codex\n", - 2_500, - ) - .await?; - - let out_2 = - exec_command(&session, &turn, "echo $CODEX_INTERACTIVE_SHELL_VAR", 2_500).await?; - tokio::time::sleep(Duration::from_secs(2)).await; - assert!( - out_2.process_id.is_none(), - "short command should not report a process id if it exits quickly" - ); - assert!( - !out_2.truncated_output().contains("codex"), - "short command should run in a fresh shell" - ); - - let out_3 = write_stdin( - &session, - shell_a.process_id.expect("expected process id"), - "echo $CODEX_INTERACTIVE_SHELL_VAR\n", - 2_500, - ) - .await?; - assert!( - out_3.truncated_output().contains("codex"), - "session should preserve state" - ); - - Ok(()) - } - - #[tokio::test] - async fn unified_exec_timeouts() -> anyhow::Result<()> { - skip_if_sandbox!(Ok(())); - - const TEST_VAR_VALUE: &str = "unified_exec_var_123"; - - let (session, turn) = test_session_and_turn().await; - - let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?; - let process_id = open_shell.process_id.expect("expected process id"); - - write_stdin( - &session, - process_id, - format!("export CODEX_INTERACTIVE_SHELL_VAR={TEST_VAR_VALUE}\n").as_str(), - 2_500, - ) - .await?; - - let out_2 = write_stdin( - &session, - process_id, - "sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n", - 10, - ) - .await?; - assert!( - !out_2.truncated_output().contains(TEST_VAR_VALUE), - "timeout too short should yield incomplete output" - ); - - tokio::time::sleep(Duration::from_secs(7)).await; - - let out_3 = write_stdin(&session, process_id, "", 100).await?; - - assert!( - out_3.truncated_output().contains(TEST_VAR_VALUE), - "subsequent poll should retrieve output" - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn unified_exec_pause_blocks_yield_timeout() -> anyhow::Result<()> { - skip_if_sandbox!(Ok(())); - - let (session, turn) = test_session_and_turn().await; - session.set_out_of_band_elicitation_pause_state(true); - - let paused_session = Arc::clone(&session); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(2)).await; - paused_session.set_out_of_band_elicitation_pause_state(false); - }); - - let started = tokio::time::Instant::now(); - let response = - exec_command(&session, &turn, "sleep 1 && echo unified-exec-done", 250).await?; - - assert!( - started.elapsed() >= Duration::from_secs(2), - "pause should block the unified exec yield timeout" - ); - assert!( - response.truncated_output().contains("unified-exec-done"), - "exec_command should wait for output after the pause lifts" - ); - assert!( - response.process_id.is_none(), - "completed command should not leave a background process" - ); - - Ok(()) - } - - #[tokio::test] - #[ignore] // Ignored while we have a better way to test this. - async fn requests_with_large_timeout_are_capped() -> anyhow::Result<()> { - let (session, turn) = test_session_and_turn().await; - - let result = exec_command(&session, &turn, "echo codex", 120_000).await?; - - assert!(result.process_id.is_some()); - assert!(result.truncated_output().contains("codex")); - - Ok(()) - } - - #[tokio::test] - #[ignore] // Ignored while we have a better way to test this. - async fn completed_commands_do_not_persist_sessions() -> anyhow::Result<()> { - let (session, turn) = test_session_and_turn().await; - let result = exec_command(&session, &turn, "echo codex", 2_500).await?; - - assert!( - result.process_id.is_some(), - "completed command should report a process id" - ); - assert!(result.truncated_output().contains("codex")); - - assert!( - session - .services - .unified_exec_manager - .process_store - .lock() - .await - .processes - .is_empty() - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn reusing_completed_process_returns_unknown_process() -> anyhow::Result<()> { - skip_if_sandbox!(Ok(())); - - let (session, turn) = test_session_and_turn().await; - - let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?; - let process_id = open_shell.process_id.expect("expected process id"); - - write_stdin(&session, process_id, "exit\n", 2_500).await?; - - tokio::time::sleep(Duration::from_millis(200)).await; - - let err = write_stdin(&session, process_id, "", 100) - .await - .expect_err("expected unknown process error"); - - match err { - UnifiedExecError::UnknownProcessId { process_id: err_id } => { - assert_eq!(err_id, process_id, "process id should match request"); - } - other => panic!("expected UnknownProcessId, got {other:?}"), - } - - assert!( - session - .services - .unified_exec_manager - .process_store - .lock() - .await - .processes - .is_empty() - ); - - Ok(()) - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs new file mode 100644 index 0000000000..c81d1329d5 --- /dev/null +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -0,0 +1,343 @@ +use super::head_tail_buffer::HeadTailBuffer; +use super::*; +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::codex::make_session_and_context; +use crate::protocol::AskForApproval; +use crate::protocol::SandboxPolicy; +use crate::tools::context::ExecCommandToolOutput; +use crate::unified_exec::ExecCommandRequest; +use crate::unified_exec::WriteStdinRequest; +use core_test_support::skip_if_sandbox; +use std::sync::Arc; +use tokio::time::Duration; + +async fn test_session_and_turn() -> (Arc, Arc) { + let (session, mut turn) = make_session_and_context().await; + turn.approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + turn.sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); + turn.file_system_sandbox_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::from(turn.sandbox_policy.get()); + turn.network_sandbox_policy = + codex_protocol::permissions::NetworkSandboxPolicy::from(turn.sandbox_policy.get()); + (Arc::new(session), Arc::new(turn)) +} + +async fn exec_command( + session: &Arc, + turn: &Arc, + cmd: &str, + yield_time_ms: u64, +) -> Result { + let context = + UnifiedExecContext::new(Arc::clone(session), Arc::clone(turn), "call".to_string()); + let process_id = session + .services + .unified_exec_manager + .allocate_process_id() + .await; + + session + .services + .unified_exec_manager + .exec_command( + ExecCommandRequest { + command: vec!["bash".to_string(), "-lc".to_string(), cmd.to_string()], + process_id, + yield_time_ms, + max_output_tokens: None, + workdir: None, + network: None, + tty: true, + sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions: None, + additional_permissions_preapproved: false, + justification: None, + prefix_rule: None, + }, + &context, + ) + .await +} + +async fn write_stdin( + session: &Arc, + process_id: i32, + input: &str, + yield_time_ms: u64, +) -> Result { + session + .services + .unified_exec_manager + .write_stdin(WriteStdinRequest { + process_id, + input, + yield_time_ms, + max_output_tokens: None, + }) + .await +} + +#[test] +fn push_chunk_preserves_prefix_and_suffix() { + let mut buffer = HeadTailBuffer::default(); + buffer.push_chunk(vec![b'a'; UNIFIED_EXEC_OUTPUT_MAX_BYTES]); + buffer.push_chunk(vec![b'b']); + buffer.push_chunk(vec![b'c']); + + assert_eq!(buffer.retained_bytes(), UNIFIED_EXEC_OUTPUT_MAX_BYTES); + let snapshot = buffer.snapshot_chunks(); + + let first = snapshot.first().expect("expected at least one chunk"); + assert_eq!(first.first(), Some(&b'a')); + assert!(snapshot.iter().any(|chunk| chunk.as_slice() == b"b")); + assert_eq!( + snapshot + .last() + .expect("expected at least one chunk") + .as_slice(), + b"c" + ); +} + +#[test] +fn head_tail_buffer_default_preserves_prefix_and_suffix() { + let mut buffer = HeadTailBuffer::default(); + buffer.push_chunk(vec![b'a'; UNIFIED_EXEC_OUTPUT_MAX_BYTES]); + buffer.push_chunk(b"bc".to_vec()); + + let rendered = buffer.to_bytes(); + assert_eq!(rendered.first(), Some(&b'a')); + assert!(rendered.ends_with(b"bc")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_persists_across_requests() -> anyhow::Result<()> { + skip_if_sandbox!(Ok(())); + + let (session, turn) = test_session_and_turn().await; + + let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?; + let process_id = open_shell.process_id.expect("expected process_id"); + + write_stdin( + &session, + process_id, + "export CODEX_INTERACTIVE_SHELL_VAR=codex\n", + 2_500, + ) + .await?; + + let out_2 = write_stdin( + &session, + process_id, + "echo $CODEX_INTERACTIVE_SHELL_VAR\n", + 2_500, + ) + .await?; + assert!( + out_2.truncated_output().contains("codex"), + "expected environment variable output" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_unified_exec_sessions() -> anyhow::Result<()> { + skip_if_sandbox!(Ok(())); + + let (session, turn) = test_session_and_turn().await; + + let shell_a = exec_command(&session, &turn, "bash -i", 2_500).await?; + let session_a = shell_a.process_id.expect("expected process id"); + + write_stdin( + &session, + session_a, + "export CODEX_INTERACTIVE_SHELL_VAR=codex\n", + 2_500, + ) + .await?; + + let out_2 = exec_command(&session, &turn, "echo $CODEX_INTERACTIVE_SHELL_VAR", 2_500).await?; + tokio::time::sleep(Duration::from_secs(2)).await; + assert!( + out_2.process_id.is_none(), + "short command should not report a process id if it exits quickly" + ); + assert!( + !out_2.truncated_output().contains("codex"), + "short command should run in a fresh shell" + ); + + let out_3 = write_stdin( + &session, + shell_a.process_id.expect("expected process id"), + "echo $CODEX_INTERACTIVE_SHELL_VAR\n", + 2_500, + ) + .await?; + assert!( + out_3.truncated_output().contains("codex"), + "session should preserve state" + ); + + Ok(()) +} + +#[tokio::test] +async fn unified_exec_timeouts() -> anyhow::Result<()> { + skip_if_sandbox!(Ok(())); + + const TEST_VAR_VALUE: &str = "unified_exec_var_123"; + + let (session, turn) = test_session_and_turn().await; + + let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?; + let process_id = open_shell.process_id.expect("expected process id"); + + write_stdin( + &session, + process_id, + format!("export CODEX_INTERACTIVE_SHELL_VAR={TEST_VAR_VALUE}\n").as_str(), + 2_500, + ) + .await?; + + let out_2 = write_stdin( + &session, + process_id, + "sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n", + 10, + ) + .await?; + assert!( + !out_2.truncated_output().contains(TEST_VAR_VALUE), + "timeout too short should yield incomplete output" + ); + + tokio::time::sleep(Duration::from_secs(7)).await; + + let out_3 = write_stdin(&session, process_id, "", 100).await?; + + assert!( + out_3.truncated_output().contains(TEST_VAR_VALUE), + "subsequent poll should retrieve output" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_pause_blocks_yield_timeout() -> anyhow::Result<()> { + skip_if_sandbox!(Ok(())); + + let (session, turn) = test_session_and_turn().await; + session.set_out_of_band_elicitation_pause_state(true); + + let paused_session = Arc::clone(&session); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(2)).await; + paused_session.set_out_of_band_elicitation_pause_state(false); + }); + + let started = tokio::time::Instant::now(); + let response = exec_command(&session, &turn, "sleep 1 && echo unified-exec-done", 250).await?; + + assert!( + started.elapsed() >= Duration::from_secs(2), + "pause should block the unified exec yield timeout" + ); + assert!( + response.truncated_output().contains("unified-exec-done"), + "exec_command should wait for output after the pause lifts" + ); + assert!( + response.process_id.is_none(), + "completed command should not leave a background process" + ); + + Ok(()) +} + +#[tokio::test] +#[ignore] // Ignored while we have a better way to test this. +async fn requests_with_large_timeout_are_capped() -> anyhow::Result<()> { + let (session, turn) = test_session_and_turn().await; + + let result = exec_command(&session, &turn, "echo codex", 120_000).await?; + + assert!(result.process_id.is_some()); + assert!(result.truncated_output().contains("codex")); + + Ok(()) +} + +#[tokio::test] +#[ignore] // Ignored while we have a better way to test this. +async fn completed_commands_do_not_persist_sessions() -> anyhow::Result<()> { + let (session, turn) = test_session_and_turn().await; + let result = exec_command(&session, &turn, "echo codex", 2_500).await?; + + assert!( + result.process_id.is_some(), + "completed command should report a process id" + ); + assert!(result.truncated_output().contains("codex")); + + assert!( + session + .services + .unified_exec_manager + .process_store + .lock() + .await + .processes + .is_empty() + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reusing_completed_process_returns_unknown_process() -> anyhow::Result<()> { + skip_if_sandbox!(Ok(())); + + let (session, turn) = test_session_and_turn().await; + + let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?; + let process_id = open_shell.process_id.expect("expected process id"); + + write_stdin(&session, process_id, "exit\n", 2_500).await?; + + tokio::time::sleep(Duration::from_millis(200)).await; + + let err = write_stdin(&session, process_id, "", 100) + .await + .expect_err("expected unknown process error"); + + match err { + UnifiedExecError::UnknownProcessId { process_id: err_id } => { + assert_eq!(err_id, process_id, "process id should match request"); + } + other => panic!("expected UnknownProcessId, got {other:?}"), + } + + assert!( + session + .services + .unified_exec_manager + .process_store + .lock() + .await + .processes + .is_empty() + ); + + Ok(()) +} diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 29311b1ff4..9eb269c07d 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -585,6 +585,7 @@ impl UnifiedExecProcessManager { command: &request.command, approval_policy: context.turn.approval_policy.value(), sandbox_policy: context.turn.sandbox_policy.get(), + file_system_sandbox_policy: &context.turn.file_system_sandbox_policy, sandbox_permissions: if request.additional_permissions_preapproved { crate::sandboxing::SandboxPermissions::UseDefault } else { @@ -602,6 +603,8 @@ impl UnifiedExecProcessManager { tty: request.tty, sandbox_permissions: request.sandbox_permissions, additional_permissions: request.additional_permissions.clone(), + #[cfg(unix)] + additional_permissions_preapproved: request.additional_permissions_preapproved, justification: request.justification.clone(), exec_approval_requirement, }; @@ -829,104 +832,5 @@ enum ProcessStatus { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tokio::time::Duration; - use tokio::time::Instant; - - #[test] - fn unified_exec_env_injects_defaults() { - let env = apply_unified_exec_env(HashMap::new()); - let expected = HashMap::from([ - ("NO_COLOR".to_string(), "1".to_string()), - ("TERM".to_string(), "dumb".to_string()), - ("LANG".to_string(), "C.UTF-8".to_string()), - ("LC_CTYPE".to_string(), "C.UTF-8".to_string()), - ("LC_ALL".to_string(), "C.UTF-8".to_string()), - ("COLORTERM".to_string(), String::new()), - ("PAGER".to_string(), "cat".to_string()), - ("GIT_PAGER".to_string(), "cat".to_string()), - ("GH_PAGER".to_string(), "cat".to_string()), - ("CODEX_CI".to_string(), "1".to_string()), - ]); - - assert_eq!(env, expected); - } - - #[test] - fn unified_exec_env_overrides_existing_values() { - let mut base = HashMap::new(); - base.insert("NO_COLOR".to_string(), "0".to_string()); - base.insert("PATH".to_string(), "/usr/bin".to_string()); - - let env = apply_unified_exec_env(base); - - assert_eq!(env.get("NO_COLOR"), Some(&"1".to_string())); - assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_string())); - } - - #[test] - fn pruning_prefers_exited_processes_outside_recently_used() { - let now = Instant::now(); - let meta = vec![ - (1, now - Duration::from_secs(40), false), - (2, now - Duration::from_secs(30), true), - (3, now - Duration::from_secs(20), false), - (4, now - Duration::from_secs(19), false), - (5, now - Duration::from_secs(18), false), - (6, now - Duration::from_secs(17), false), - (7, now - Duration::from_secs(16), false), - (8, now - Duration::from_secs(15), false), - (9, now - Duration::from_secs(14), false), - (10, now - Duration::from_secs(13), false), - ]; - - let candidate = UnifiedExecProcessManager::process_id_to_prune_from_meta(&meta); - - assert_eq!(candidate, Some(2)); - } - - #[test] - fn pruning_falls_back_to_lru_when_no_exited() { - let now = Instant::now(); - let meta = vec![ - (1, now - Duration::from_secs(40), false), - (2, now - Duration::from_secs(30), false), - (3, now - Duration::from_secs(20), false), - (4, now - Duration::from_secs(19), false), - (5, now - Duration::from_secs(18), false), - (6, now - Duration::from_secs(17), false), - (7, now - Duration::from_secs(16), false), - (8, now - Duration::from_secs(15), false), - (9, now - Duration::from_secs(14), false), - (10, now - Duration::from_secs(13), false), - ]; - - let candidate = UnifiedExecProcessManager::process_id_to_prune_from_meta(&meta); - - assert_eq!(candidate, Some(1)); - } - - #[test] - fn pruning_protects_recent_processes_even_if_exited() { - let now = Instant::now(); - let meta = vec![ - (1, now - Duration::from_secs(40), false), - (2, now - Duration::from_secs(30), false), - (3, now - Duration::from_secs(20), true), - (4, now - Duration::from_secs(19), false), - (5, now - Duration::from_secs(18), false), - (6, now - Duration::from_secs(17), false), - (7, now - Duration::from_secs(16), false), - (8, now - Duration::from_secs(15), false), - (9, now - Duration::from_secs(14), false), - (10, now - Duration::from_secs(13), true), - ]; - - let candidate = UnifiedExecProcessManager::process_id_to_prune_from_meta(&meta); - - // (10) is exited but among the last 8; we should drop the LRU outside that set. - assert_eq!(candidate, Some(1)); - } -} +#[path = "process_manager_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs new file mode 100644 index 0000000000..b145dadb09 --- /dev/null +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -0,0 +1,99 @@ +use super::*; +use pretty_assertions::assert_eq; +use tokio::time::Duration; +use tokio::time::Instant; + +#[test] +fn unified_exec_env_injects_defaults() { + let env = apply_unified_exec_env(HashMap::new()); + let expected = HashMap::from([ + ("NO_COLOR".to_string(), "1".to_string()), + ("TERM".to_string(), "dumb".to_string()), + ("LANG".to_string(), "C.UTF-8".to_string()), + ("LC_CTYPE".to_string(), "C.UTF-8".to_string()), + ("LC_ALL".to_string(), "C.UTF-8".to_string()), + ("COLORTERM".to_string(), String::new()), + ("PAGER".to_string(), "cat".to_string()), + ("GIT_PAGER".to_string(), "cat".to_string()), + ("GH_PAGER".to_string(), "cat".to_string()), + ("CODEX_CI".to_string(), "1".to_string()), + ]); + + assert_eq!(env, expected); +} + +#[test] +fn unified_exec_env_overrides_existing_values() { + let mut base = HashMap::new(); + base.insert("NO_COLOR".to_string(), "0".to_string()); + base.insert("PATH".to_string(), "/usr/bin".to_string()); + + let env = apply_unified_exec_env(base); + + assert_eq!(env.get("NO_COLOR"), Some(&"1".to_string())); + assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_string())); +} + +#[test] +fn pruning_prefers_exited_processes_outside_recently_used() { + let now = Instant::now(); + let meta = vec![ + (1, now - Duration::from_secs(40), false), + (2, now - Duration::from_secs(30), true), + (3, now - Duration::from_secs(20), false), + (4, now - Duration::from_secs(19), false), + (5, now - Duration::from_secs(18), false), + (6, now - Duration::from_secs(17), false), + (7, now - Duration::from_secs(16), false), + (8, now - Duration::from_secs(15), false), + (9, now - Duration::from_secs(14), false), + (10, now - Duration::from_secs(13), false), + ]; + + let candidate = UnifiedExecProcessManager::process_id_to_prune_from_meta(&meta); + + assert_eq!(candidate, Some(2)); +} + +#[test] +fn pruning_falls_back_to_lru_when_no_exited() { + let now = Instant::now(); + let meta = vec![ + (1, now - Duration::from_secs(40), false), + (2, now - Duration::from_secs(30), false), + (3, now - Duration::from_secs(20), false), + (4, now - Duration::from_secs(19), false), + (5, now - Duration::from_secs(18), false), + (6, now - Duration::from_secs(17), false), + (7, now - Duration::from_secs(16), false), + (8, now - Duration::from_secs(15), false), + (9, now - Duration::from_secs(14), false), + (10, now - Duration::from_secs(13), false), + ]; + + let candidate = UnifiedExecProcessManager::process_id_to_prune_from_meta(&meta); + + assert_eq!(candidate, Some(1)); +} + +#[test] +fn pruning_protects_recent_processes_even_if_exited() { + let now = Instant::now(); + let meta = vec![ + (1, now - Duration::from_secs(40), false), + (2, now - Duration::from_secs(30), false), + (3, now - Duration::from_secs(20), true), + (4, now - Duration::from_secs(19), false), + (5, now - Duration::from_secs(18), false), + (6, now - Duration::from_secs(17), false), + (7, now - Duration::from_secs(16), false), + (8, now - Duration::from_secs(15), false), + (9, now - Duration::from_secs(14), false), + (10, now - Duration::from_secs(13), true), + ]; + + let candidate = UnifiedExecProcessManager::process_id_to_prune_from_meta(&meta); + + // (10) is exited but among the last 8; we should drop the LRU outside that set. + assert_eq!(candidate, Some(1)); +} diff --git a/codex-rs/core/src/user_shell_command.rs b/codex-rs/core/src/user_shell_command.rs index e7921c69f3..32cf78cf2a 100644 --- a/codex-rs/core/src/user_shell_command.rs +++ b/codex-rs/core/src/user_shell_command.rs @@ -55,61 +55,5 @@ pub fn user_shell_command_record_item( } #[cfg(test)] -mod tests { - use super::*; - use crate::codex::make_session_and_context; - use crate::exec::StreamOutput; - use codex_protocol::models::ContentItem; - use pretty_assertions::assert_eq; - - #[test] - fn detects_user_shell_command_text_variants() { - assert!( - USER_SHELL_COMMAND_FRAGMENT - .matches_text("\necho hi\n") - ); - assert!(!USER_SHELL_COMMAND_FRAGMENT.matches_text("echo hi")); - } - - #[tokio::test] - async fn formats_basic_record() { - let exec_output = ExecToolCallOutput { - exit_code: 0, - stdout: StreamOutput::new("hi".to_string()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new("hi".to_string()), - duration: Duration::from_secs(1), - timed_out: false, - }; - let (_, turn_context) = make_session_and_context().await; - let item = user_shell_command_record_item("echo hi", &exec_output, &turn_context); - let ResponseItem::Message { content, .. } = item else { - panic!("expected message"); - }; - let [ContentItem::InputText { text }] = content.as_slice() else { - panic!("expected input text"); - }; - assert_eq!( - text, - "\n\necho hi\n\n\nExit code: 0\nDuration: 1.0000 seconds\nOutput:\nhi\n\n" - ); - } - - #[tokio::test] - async fn uses_aggregated_output_over_streams() { - let exec_output = ExecToolCallOutput { - exit_code: 42, - stdout: StreamOutput::new("stdout-only".to_string()), - stderr: StreamOutput::new("stderr-only".to_string()), - aggregated_output: StreamOutput::new("combined output wins".to_string()), - duration: Duration::from_millis(120), - timed_out: false, - }; - let (_, turn_context) = make_session_and_context().await; - let record = format_user_shell_command_record("false", &exec_output, &turn_context); - assert_eq!( - record, - "\n\nfalse\n\n\nExit code: 42\nDuration: 0.1200 seconds\nOutput:\ncombined output wins\n\n" - ); - } -} +#[path = "user_shell_command_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/user_shell_command_tests.rs b/codex-rs/core/src/user_shell_command_tests.rs new file mode 100644 index 0000000000..a034f404e5 --- /dev/null +++ b/codex-rs/core/src/user_shell_command_tests.rs @@ -0,0 +1,56 @@ +use super::*; +use crate::codex::make_session_and_context; +use crate::exec::StreamOutput; +use codex_protocol::models::ContentItem; +use pretty_assertions::assert_eq; + +#[test] +fn detects_user_shell_command_text_variants() { + assert!( + USER_SHELL_COMMAND_FRAGMENT + .matches_text("\necho hi\n") + ); + assert!(!USER_SHELL_COMMAND_FRAGMENT.matches_text("echo hi")); +} + +#[tokio::test] +async fn formats_basic_record() { + let exec_output = ExecToolCallOutput { + exit_code: 0, + stdout: StreamOutput::new("hi".to_string()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new("hi".to_string()), + duration: Duration::from_secs(1), + timed_out: false, + }; + let (_, turn_context) = make_session_and_context().await; + let item = user_shell_command_record_item("echo hi", &exec_output, &turn_context); + let ResponseItem::Message { content, .. } = item else { + panic!("expected message"); + }; + let [ContentItem::InputText { text }] = content.as_slice() else { + panic!("expected input text"); + }; + assert_eq!( + text, + "\n\necho hi\n\n\nExit code: 0\nDuration: 1.0000 seconds\nOutput:\nhi\n\n" + ); +} + +#[tokio::test] +async fn uses_aggregated_output_over_streams() { + let exec_output = ExecToolCallOutput { + exit_code: 42, + stdout: StreamOutput::new("stdout-only".to_string()), + stderr: StreamOutput::new("stderr-only".to_string()), + aggregated_output: StreamOutput::new("combined output wins".to_string()), + duration: Duration::from_millis(120), + timed_out: false, + }; + let (_, turn_context) = make_session_and_context().await; + let record = format_user_shell_command_record("false", &exec_output, &turn_context); + assert_eq!( + record, + "\n\nfalse\n\n\nExit code: 42\nDuration: 0.1200 seconds\nOutput:\ncombined output wins\n\n" + ); +} diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index 59fecb0a9e..62e872ae6c 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -102,85 +102,5 @@ pub fn resume_command(thread_name: Option<&str>, thread_id: Option) -> } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_try_parse_error_message() { - let text = r#"{ - "error": { - "message": "Your refresh token has already been used to generate a new access token. Please try signing in again.", - "type": "invalid_request_error", - "param": null, - "code": "refresh_token_reused" - } -}"#; - let message = try_parse_error_message(text); - assert_eq!( - message, - "Your refresh token has already been used to generate a new access token. Please try signing in again." - ); - } - - #[test] - fn test_try_parse_error_message_no_error() { - let text = r#"{"message": "test"}"#; - let message = try_parse_error_message(text); - assert_eq!(message, r#"{"message": "test"}"#); - } - - #[test] - fn feedback_tags_macro_compiles() { - #[derive(Debug)] - struct OnlyDebug; - - feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug); - } - - #[test] - fn normalize_thread_name_trims_and_rejects_empty() { - assert_eq!(normalize_thread_name(" "), None); - assert_eq!( - normalize_thread_name(" my thread "), - Some("my thread".to_string()) - ); - } - - #[test] - fn resume_command_prefers_name_over_id() { - let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - let command = resume_command(Some("my-thread"), Some(thread_id)); - assert_eq!(command, Some("codex resume my-thread".to_string())); - } - - #[test] - fn resume_command_with_only_id() { - let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - let command = resume_command(None, Some(thread_id)); - assert_eq!( - command, - Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) - ); - } - - #[test] - fn resume_command_with_no_name_or_id() { - let command = resume_command(None, None); - assert_eq!(command, None); - } - - #[test] - fn resume_command_quotes_thread_name_when_needed() { - let command = resume_command(Some("-starts-with-dash"), None); - assert_eq!( - command, - Some("codex resume -- -starts-with-dash".to_string()) - ); - - let command = resume_command(Some("two words"), None); - assert_eq!(command, Some("codex resume 'two words'".to_string())); - - let command = resume_command(Some("quote'case"), None); - assert_eq!(command, Some("codex resume \"quote'case\"".to_string())); - } -} +#[path = "util_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/util_tests.rs b/codex-rs/core/src/util_tests.rs new file mode 100644 index 0000000000..dd5956bf61 --- /dev/null +++ b/codex-rs/core/src/util_tests.rs @@ -0,0 +1,80 @@ +use super::*; + +#[test] +fn test_try_parse_error_message() { + let text = r#"{ + "error": { + "message": "Your refresh token has already been used to generate a new access token. Please try signing in again.", + "type": "invalid_request_error", + "param": null, + "code": "refresh_token_reused" + } +}"#; + let message = try_parse_error_message(text); + assert_eq!( + message, + "Your refresh token has already been used to generate a new access token. Please try signing in again." + ); +} + +#[test] +fn test_try_parse_error_message_no_error() { + let text = r#"{"message": "test"}"#; + let message = try_parse_error_message(text); + assert_eq!(message, r#"{"message": "test"}"#); +} + +#[test] +fn feedback_tags_macro_compiles() { + #[derive(Debug)] + struct OnlyDebug; + + feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug); +} + +#[test] +fn normalize_thread_name_trims_and_rejects_empty() { + assert_eq!(normalize_thread_name(" "), None); + assert_eq!( + normalize_thread_name(" my thread "), + Some("my thread".to_string()) + ); +} + +#[test] +fn resume_command_prefers_name_over_id() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let command = resume_command(Some("my-thread"), Some(thread_id)); + assert_eq!(command, Some("codex resume my-thread".to_string())); +} + +#[test] +fn resume_command_with_only_id() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let command = resume_command(None, Some(thread_id)); + assert_eq!( + command, + Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + ); +} + +#[test] +fn resume_command_with_no_name_or_id() { + let command = resume_command(None, None); + assert_eq!(command, None); +} + +#[test] +fn resume_command_quotes_thread_name_when_needed() { + let command = resume_command(Some("-starts-with-dash"), None); + assert_eq!( + command, + Some("codex resume -- -starts-with-dash".to_string()) + ); + + let command = resume_command(Some("two words"), None); + assert_eq!(command, Some("codex resume 'two words'".to_string())); + + let command = resume_command(Some("quote'case"), None); + assert_eq!(command, Some("codex resume \"quote'case\"".to_string())); +} diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 6e0067aa09..25932a9622 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -427,137 +427,5 @@ fn windows_sandbox_setup_mode_tag(mode: WindowsSandboxSetupMode) -> &'static str } #[cfg(test)] -mod tests { - use super::*; - use crate::config::types::WindowsToml; - use crate::features::Features; - use crate::features::FeaturesToml; - use pretty_assertions::assert_eq; - use std::collections::BTreeMap; - - #[test] - fn elevated_flag_works_by_itself() { - let mut features = Features::with_defaults(); - features.enable(Feature::WindowsSandboxElevated); - - assert_eq!( - WindowsSandboxLevel::from_features(&features), - WindowsSandboxLevel::Elevated - ); - } - - #[test] - fn restricted_token_flag_works_by_itself() { - let mut features = Features::with_defaults(); - features.enable(Feature::WindowsSandbox); - - assert_eq!( - WindowsSandboxLevel::from_features(&features), - WindowsSandboxLevel::RestrictedToken - ); - } - - #[test] - fn no_flags_means_no_sandbox() { - let features = Features::with_defaults(); - - assert_eq!( - WindowsSandboxLevel::from_features(&features), - WindowsSandboxLevel::Disabled - ); - } - - #[test] - fn elevated_wins_when_both_flags_are_enabled() { - let mut features = Features::with_defaults(); - features.enable(Feature::WindowsSandbox); - features.enable(Feature::WindowsSandboxElevated); - - assert_eq!( - WindowsSandboxLevel::from_features(&features), - WindowsSandboxLevel::Elevated - ); - } - - #[test] - fn legacy_mode_prefers_elevated() { - let mut entries = BTreeMap::new(); - entries.insert("experimental_windows_sandbox".to_string(), true); - entries.insert("elevated_windows_sandbox".to_string(), true); - - assert_eq!( - legacy_windows_sandbox_mode_from_entries(&entries), - Some(WindowsSandboxModeToml::Elevated) - ); - } - - #[test] - fn legacy_mode_supports_alias_key() { - let mut entries = BTreeMap::new(); - entries.insert("enable_experimental_windows_sandbox".to_string(), true); - - assert_eq!( - legacy_windows_sandbox_mode_from_entries(&entries), - Some(WindowsSandboxModeToml::Unelevated) - ); - } - - #[test] - fn resolve_windows_sandbox_mode_prefers_profile_windows() { - let cfg = ConfigToml { - windows: Some(WindowsToml { - sandbox: Some(WindowsSandboxModeToml::Unelevated), - }), - ..Default::default() - }; - let profile = ConfigProfile { - windows: Some(WindowsToml { - sandbox: Some(WindowsSandboxModeToml::Elevated), - }), - ..Default::default() - }; - - assert_eq!( - resolve_windows_sandbox_mode(&cfg, &profile), - Some(WindowsSandboxModeToml::Elevated) - ); - } - - #[test] - fn resolve_windows_sandbox_mode_falls_back_to_legacy_keys() { - let mut entries = BTreeMap::new(); - entries.insert("experimental_windows_sandbox".to_string(), true); - let cfg = ConfigToml { - features: Some(FeaturesToml { entries }), - ..Default::default() - }; - - assert_eq!( - resolve_windows_sandbox_mode(&cfg, &ConfigProfile::default()), - Some(WindowsSandboxModeToml::Unelevated) - ); - } - - #[test] - fn resolve_windows_sandbox_mode_profile_legacy_false_blocks_top_level_legacy_true() { - let mut profile_entries = BTreeMap::new(); - profile_entries.insert("experimental_windows_sandbox".to_string(), false); - let profile = ConfigProfile { - features: Some(FeaturesToml { - entries: profile_entries, - }), - ..Default::default() - }; - - let mut cfg_entries = BTreeMap::new(); - cfg_entries.insert("experimental_windows_sandbox".to_string(), true); - let cfg = ConfigToml { - features: Some(FeaturesToml { - entries: cfg_entries, - }), - ..Default::default() - }; - - assert_eq!(resolve_windows_sandbox_mode(&cfg, &profile), None); - } -} +#[path = "windows_sandbox_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/windows_sandbox_read_grants.rs b/codex-rs/core/src/windows_sandbox_read_grants.rs index 8fa843c8b4..ec08e55cfd 100644 --- a/codex-rs/core/src/windows_sandbox_read_grants.rs +++ b/codex-rs/core/src/windows_sandbox_read_grants.rs @@ -36,62 +36,5 @@ pub fn grant_read_root_non_elevated( } #[cfg(test)] -mod tests { - use super::grant_read_root_non_elevated; - use crate::protocol::SandboxPolicy; - use std::collections::HashMap; - use std::path::Path; - use tempfile::TempDir; - - fn policy() -> SandboxPolicy { - SandboxPolicy::new_workspace_write_policy() - } - - #[test] - fn rejects_relative_path() { - let tmp = TempDir::new().expect("tempdir"); - let err = grant_read_root_non_elevated( - &policy(), - tmp.path(), - tmp.path(), - &HashMap::new(), - tmp.path(), - Path::new("relative"), - ) - .expect_err("relative path should fail"); - assert!(err.to_string().contains("path must be absolute")); - } - - #[test] - fn rejects_missing_path() { - let tmp = TempDir::new().expect("tempdir"); - let missing = tmp.path().join("does-not-exist"); - let err = grant_read_root_non_elevated( - &policy(), - tmp.path(), - tmp.path(), - &HashMap::new(), - tmp.path(), - missing.as_path(), - ) - .expect_err("missing path should fail"); - assert!(err.to_string().contains("path does not exist")); - } - - #[test] - fn rejects_file_path() { - let tmp = TempDir::new().expect("tempdir"); - let file_path = tmp.path().join("file.txt"); - std::fs::write(&file_path, "hello").expect("write file"); - let err = grant_read_root_non_elevated( - &policy(), - tmp.path(), - tmp.path(), - &HashMap::new(), - tmp.path(), - file_path.as_path(), - ) - .expect_err("file path should fail"); - assert!(err.to_string().contains("path must be a directory")); - } -} +#[path = "windows_sandbox_read_grants_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/windows_sandbox_read_grants_tests.rs b/codex-rs/core/src/windows_sandbox_read_grants_tests.rs new file mode 100644 index 0000000000..c23920264b --- /dev/null +++ b/codex-rs/core/src/windows_sandbox_read_grants_tests.rs @@ -0,0 +1,57 @@ +use super::grant_read_root_non_elevated; +use crate::protocol::SandboxPolicy; +use std::collections::HashMap; +use std::path::Path; +use tempfile::TempDir; + +fn policy() -> SandboxPolicy { + SandboxPolicy::new_workspace_write_policy() +} + +#[test] +fn rejects_relative_path() { + let tmp = TempDir::new().expect("tempdir"); + let err = grant_read_root_non_elevated( + &policy(), + tmp.path(), + tmp.path(), + &HashMap::new(), + tmp.path(), + Path::new("relative"), + ) + .expect_err("relative path should fail"); + assert!(err.to_string().contains("path must be absolute")); +} + +#[test] +fn rejects_missing_path() { + let tmp = TempDir::new().expect("tempdir"); + let missing = tmp.path().join("does-not-exist"); + let err = grant_read_root_non_elevated( + &policy(), + tmp.path(), + tmp.path(), + &HashMap::new(), + tmp.path(), + missing.as_path(), + ) + .expect_err("missing path should fail"); + assert!(err.to_string().contains("path does not exist")); +} + +#[test] +fn rejects_file_path() { + let tmp = TempDir::new().expect("tempdir"); + let file_path = tmp.path().join("file.txt"); + std::fs::write(&file_path, "hello").expect("write file"); + let err = grant_read_root_non_elevated( + &policy(), + tmp.path(), + tmp.path(), + &HashMap::new(), + tmp.path(), + file_path.as_path(), + ) + .expect_err("file path should fail"); + assert!(err.to_string().contains("path must be a directory")); +} diff --git a/codex-rs/core/src/windows_sandbox_tests.rs b/codex-rs/core/src/windows_sandbox_tests.rs new file mode 100644 index 0000000000..6bcd493ad4 --- /dev/null +++ b/codex-rs/core/src/windows_sandbox_tests.rs @@ -0,0 +1,132 @@ +use super::*; +use crate::config::types::WindowsToml; +use crate::features::Features; +use crate::features::FeaturesToml; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; + +#[test] +fn elevated_flag_works_by_itself() { + let mut features = Features::with_defaults(); + features.enable(Feature::WindowsSandboxElevated); + + assert_eq!( + WindowsSandboxLevel::from_features(&features), + WindowsSandboxLevel::Elevated + ); +} + +#[test] +fn restricted_token_flag_works_by_itself() { + let mut features = Features::with_defaults(); + features.enable(Feature::WindowsSandbox); + + assert_eq!( + WindowsSandboxLevel::from_features(&features), + WindowsSandboxLevel::RestrictedToken + ); +} + +#[test] +fn no_flags_means_no_sandbox() { + let features = Features::with_defaults(); + + assert_eq!( + WindowsSandboxLevel::from_features(&features), + WindowsSandboxLevel::Disabled + ); +} + +#[test] +fn elevated_wins_when_both_flags_are_enabled() { + let mut features = Features::with_defaults(); + features.enable(Feature::WindowsSandbox); + features.enable(Feature::WindowsSandboxElevated); + + assert_eq!( + WindowsSandboxLevel::from_features(&features), + WindowsSandboxLevel::Elevated + ); +} + +#[test] +fn legacy_mode_prefers_elevated() { + let mut entries = BTreeMap::new(); + entries.insert("experimental_windows_sandbox".to_string(), true); + entries.insert("elevated_windows_sandbox".to_string(), true); + + assert_eq!( + legacy_windows_sandbox_mode_from_entries(&entries), + Some(WindowsSandboxModeToml::Elevated) + ); +} + +#[test] +fn legacy_mode_supports_alias_key() { + let mut entries = BTreeMap::new(); + entries.insert("enable_experimental_windows_sandbox".to_string(), true); + + assert_eq!( + legacy_windows_sandbox_mode_from_entries(&entries), + Some(WindowsSandboxModeToml::Unelevated) + ); +} + +#[test] +fn resolve_windows_sandbox_mode_prefers_profile_windows() { + let cfg = ConfigToml { + windows: Some(WindowsToml { + sandbox: Some(WindowsSandboxModeToml::Unelevated), + }), + ..Default::default() + }; + let profile = ConfigProfile { + windows: Some(WindowsToml { + sandbox: Some(WindowsSandboxModeToml::Elevated), + }), + ..Default::default() + }; + + assert_eq!( + resolve_windows_sandbox_mode(&cfg, &profile), + Some(WindowsSandboxModeToml::Elevated) + ); +} + +#[test] +fn resolve_windows_sandbox_mode_falls_back_to_legacy_keys() { + let mut entries = BTreeMap::new(); + entries.insert("experimental_windows_sandbox".to_string(), true); + let cfg = ConfigToml { + features: Some(FeaturesToml { entries }), + ..Default::default() + }; + + assert_eq!( + resolve_windows_sandbox_mode(&cfg, &ConfigProfile::default()), + Some(WindowsSandboxModeToml::Unelevated) + ); +} + +#[test] +fn resolve_windows_sandbox_mode_profile_legacy_false_blocks_top_level_legacy_true() { + let mut profile_entries = BTreeMap::new(); + profile_entries.insert("experimental_windows_sandbox".to_string(), false); + let profile = ConfigProfile { + features: Some(FeaturesToml { + entries: profile_entries, + }), + ..Default::default() + }; + + let mut cfg_entries = BTreeMap::new(); + cfg_entries.insert("experimental_windows_sandbox".to_string(), true); + let cfg = ConfigToml { + features: Some(FeaturesToml { + entries: cfg_entries, + }), + ..Default::default() + }; + + assert_eq!(resolve_windows_sandbox_mode(&cfg, &profile), None); +} diff --git a/codex-rs/core/templates/search_tool/tool_description.md b/codex-rs/core/templates/search_tool/tool_description.md index 05667b7356..07df9dc511 100644 --- a/codex-rs/core/templates/search_tool/tool_description.md +++ b/codex-rs/core/templates/search_tool/tool_description.md @@ -2,27 +2,4 @@ Searches over apps tool metadata with BM25 and exposes matching tools for the next model call. -MCP tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`search_tool_bm25`). - -Follow this workflow: - -1. Call `search_tool_bm25` with: - - `query` (required): focused terms that describe the capability you need. - - `limit` (optional): maximum number of tools to return (default `8`). -2. Use the returned `tools` list to decide which Apps tools are relevant. -3. Matching tools are added to available `tools` and available for the remainder of the current session/thread. -4. Repeated searches in the same session/thread are additive: new matches are unioned into `tools`. - -Notes: -- Core tools remain available without searching. -- If you are unsure, start with `limit` between 5 and 10 to see a broader set of tools. -- `query` is matched against Apps tool metadata fields: - - `name` - - `tool_name` - - `server_name` - - `title` - - `description` - - `connector_name` - - input schema property keys (`input_keys`) -- If the needed app is already explicit in the prompt (for example `[$app-name](app://{connector_id})`) or already present in the current `tools` list, you can call that tool directly. -- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to {{app_names}}. +Tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`tool_search`). \ No newline at end of file diff --git a/codex-rs/core/templates/search_tool/tool_suggest_description.md b/codex-rs/core/templates/search_tool/tool_suggest_description.md new file mode 100644 index 0000000000..fcf599c397 --- /dev/null +++ b/codex-rs/core/templates/search_tool/tool_suggest_description.md @@ -0,0 +1,25 @@ +# Tool suggestion discovery + +Suggests a discoverable connector or plugin when the user clearly wants a capability that is not currently available in the active `tools` list. + +Use this ONLY when: +- There's no available tool to handle the user's request +- And tool_search fails to find a good match +- AND the user's request strongly matches one of the discoverable tools listed below. + +Tool suggestions should only use the discoverable tools listed here. DO NOT explore or recommend tools that are not on this list. + +Discoverable tools: +{{discoverable_tools}} + +Workflow: + +1. Match the user's request against the discoverable tools list above. +2. If one tool clearly fits, call `tool_suggest` with: + - `tool_type`: `connector` or `plugin` + - `action_type`: `install` or `enable` + - `tool_id`: exact id from the discoverable tools list above + - `suggest_reason`: concise one-line user-facing reason this tool can help with the current request +3. After the suggestion flow completes: + - if the user finished the install or enable flow, continue by searching again or using the newly available tool + - if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks you to. diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 1160a125a1..83ce020bef 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -12,6 +12,9 @@ use wiremock::matchers::path_regex; const CONNECTOR_ID: &str = "calendar"; const CONNECTOR_NAME: &str = "Calendar"; +const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44"; +const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; +const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar."; const PROTOCOL_VERSION: &str = "2025-11-25"; const SERVER_NAME: &str = "codex-apps-test"; const SERVER_VERSION: &str = "1.0.0"; @@ -31,7 +34,13 @@ impl AppsTestServer { connector_name: &str, ) -> Result { mount_oauth_metadata(server).await; - mount_streamable_http_json_rpc(server, connector_name.to_string()).await; + mount_connectors_directory(server).await; + mount_streamable_http_json_rpc( + server, + connector_name.to_string(), + CONNECTOR_DESCRIPTION.to_string(), + ) + .await; Ok(Self { chatgpt_base_url: server.uri(), }) @@ -50,16 +59,55 @@ async fn mount_oauth_metadata(server: &MockServer) { .await; } -async fn mount_streamable_http_json_rpc(server: &MockServer, connector_name: String) { +async fn mount_connectors_directory(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/connectors/directory/list")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "apps": [ + { + "id": DISCOVERABLE_CALENDAR_ID, + "name": "Google Calendar", + "description": "Plan events and schedules.", + }, + { + "id": DISCOVERABLE_GMAIL_ID, + "name": "Gmail", + "description": "Find and summarize email threads.", + } + ], + "nextToken": null + }))) + .mount(server) + .await; + + Mock::given(method("GET")) + .and(path("/connectors/directory/list_workspace")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "apps": [], + "nextToken": null + }))) + .mount(server) + .await; +} + +async fn mount_streamable_http_json_rpc( + server: &MockServer, + connector_name: String, + connector_description: String, +) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) - .respond_with(CodexAppsJsonRpcResponder { connector_name }) + .respond_with(CodexAppsJsonRpcResponder { + connector_name, + connector_description, + }) .mount(server) .await; } struct CodexAppsJsonRpcResponder { connector_name: String, + connector_description: String, } impl Respond for CodexAppsJsonRpcResponder { @@ -126,7 +174,8 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, - "connector_name": self.connector_name.clone() + "connector_name": self.connector_name.clone(), + "connector_description": self.connector_description.clone() } }, { @@ -142,7 +191,8 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, - "connector_name": self.connector_name.clone() + "connector_name": self.connector_name.clone(), + "connector_description": self.connector_description.clone() } } ], @@ -150,6 +200,33 @@ impl Respond for CodexAppsJsonRpcResponder { } })) } + "tools/call" => { + let id = body.get("id").cloned().unwrap_or(Value::Null); + let tool_name = body + .pointer("/params/name") + .and_then(Value::as_str) + .unwrap_or_default(); + let title = body + .pointer("/params/arguments/title") + .and_then(Value::as_str) + .unwrap_or_default(); + let starts_at = body + .pointer("/params/arguments/starts_at") + .and_then(Value::as_str) + .unwrap_or_default(); + + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{ + "type": "text", + "text": format!("called {tool_name} for {title} at {starts_at}") + }], + "isError": false + } + })) + } method if method.starts_with("notifications/") => ResponseTemplate::new(202), _ => { let id = body.get("id").cloned().unwrap_or(Value::Null); diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index f580b1d4ad..2baef5979a 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -207,6 +207,10 @@ impl ResponsesRequest { self.call_output(call_id, "custom_tool_call_output") } + pub fn tool_search_output(&self, call_id: &str) -> Value { + self.call_output(call_id, "tool_search_output") + } + pub fn call_output(&self, call_id: &str, call_type: &str) -> Value { self.input() .iter() @@ -775,6 +779,18 @@ pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { }) } +pub fn ev_tool_search_call(call_id: &str, arguments: &serde_json::Value) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "tool_search_call", + "call_id": call_id, + "execution": "client", + "arguments": arguments, + } + }) +} + pub fn ev_custom_tool_call(call_id: &str, name: &str, input: &str) -> Value { serde_json::json!({ "type": "response.output_item.done", @@ -1485,11 +1501,13 @@ pub async fn mount_response_sequence( /// Validate invariants on the request body sent to `/v1/responses`. /// /// - No `function_call_output`/`custom_tool_call_output` with missing/empty `call_id`. +/// - `tool_search_output` must have a `call_id` unless it is a server-executed legacy item. /// - Every `function_call_output` must match a prior `function_call` or /// `local_shell_call` with the same `call_id` in the same `input`. /// - Every `custom_tool_call_output` must match a prior `custom_tool_call`. -/// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call` -/// in the `input` must have a matching output entry. +/// - Every `tool_search_output` must match a prior `tool_search_call`. +/// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call`/ +/// `tool_search_call` in the `input` must have a matching output entry. fn validate_request_body_invariants(request: &wiremock::Request) { // Skip GET requests (e.g., /models) if request.method != "POST" || !request.url.path().ends_with("/responses") { @@ -1539,7 +1557,24 @@ fn validate_request_body_invariants(request: &wiremock::Request) { .collect() } + fn gather_tool_search_output_ids(items: &[Value]) -> HashSet { + items + .iter() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_search_output")) + .filter_map(|item| { + if let Some(id) = get_call_id(item) { + return Some(id.to_string()); + } + if item.get("execution").and_then(Value::as_str) == Some("server") { + return None; + } + panic!("orphan tool_search_output with empty call_id should be dropped"); + }) + .collect() + } + let function_calls = gather_ids(items, "function_call"); + let tool_search_calls = gather_ids(items, "tool_search_call"); let custom_tool_calls = gather_ids(items, "custom_tool_call"); let local_shell_calls = gather_ids(items, "local_shell_call"); let function_call_outputs = gather_output_ids( @@ -1547,6 +1582,7 @@ fn validate_request_body_invariants(request: &wiremock::Request) { "function_call_output", "orphan function_call_output with empty call_id should be dropped", ); + let tool_search_outputs = gather_tool_search_output_ids(items); let custom_tool_call_outputs = gather_output_ids( items, "custom_tool_call_output", @@ -1565,6 +1601,12 @@ fn validate_request_body_invariants(request: &wiremock::Request) { "custom_tool_call_output without matching call in input: {cid}", ); } + for cid in &tool_search_outputs { + assert!( + tool_search_calls.contains(cid), + "tool_search_output without matching call in input: {cid}", + ); + } for cid in &function_calls { assert!( @@ -1578,4 +1620,10 @@ fn validate_request_body_invariants(request: &wiremock::Request) { "Custom tool call output is missing for call id: {cid}", ); } + for cid in &tool_search_calls { + assert!( + tool_search_outputs.contains(cid), + "Tool search output is missing for call id: {cid}", + ); + } } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index ffb37a0a56..b2aaec7772 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -202,6 +202,7 @@ impl TestCodexBuilder { config.clone(), path, auth_manager, + None, )) .await? } diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index abeb792afc..dc4f1b0902 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1321,7 +1321,7 @@ fn scenarios() -> Vec { expectation: Expectation::FileNotCreated { target: TargetPath::Workspace("ro_never.txt"), message_contains: if cfg!(target_os = "linux") { - &["Permission denied"] + &["Permission denied|Read-only file system"] } else { &[ "Permission denied|Operation not permitted|operation not permitted|\ @@ -1468,7 +1468,7 @@ fn scenarios() -> Vec { expectation: Expectation::FileNotCreated { target: TargetPath::OutsideWorkspace("ww_never.txt"), message_contains: if cfg!(target_os = "linux") { - &["Permission denied"] + &["Permission denied|Read-only file system"] } else { &[ "Permission denied|Operation not permitted|operation not permitted|\ @@ -2290,20 +2290,16 @@ allow_local_binding = true test.config.permissions.network.is_some(), "expected managed network proxy config to be present" ); - let runtime_proxy = test - .session_configured + test.session_configured .network_proxy .as_ref() .expect("expected runtime managed network proxy addresses"); - let proxy_addr = runtime_proxy.http_addr.as_str(); let call_id_first = "allow-network-first"; - // Use the same urllib-based pattern as the other network integration tests, - // but point it at the runtime proxy directly so the blocked host reliably - // produces a network approval request without relying on curl. - let fetch_command = format!( - "python3 -c \"import urllib.request; proxy = urllib.request.ProxyHandler({{'http': 'http://{proxy_addr}'}}); opener = urllib.request.build_opener(proxy); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))\"" - ); + // Use urllib without overriding proxy settings so managed-network sessions + // continue to exercise the env-based proxy routing path under bubblewrap. + let fetch_command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))""# + .to_string(); let first_event = shell_event( call_id_first, &fetch_command, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c09498cce8..436d7e9ba0 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -519,6 +519,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { item: RolloutItem::ResponseItem(ResponseItem::FunctionCall { id: None, name: "view_image".to_string(), + namespace: None, arguments: "{\"path\":\"/tmp/example.webp\"}".to_string(), call_id: function_call_id.to_string(), }), @@ -1883,6 +1884,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { prompt.input.push(ResponseItem::FunctionCall { id: Some("function-id".into()), name: "do_thing".into(), + namespace: None, arguments: "{}".into(), call_id: "function-call-id".into(), }); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 07cadc3431..976c553dc3 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -21,6 +21,7 @@ use pretty_assertions::assert_eq; use serde_json::Value; use std::collections::HashMap; use std::fs; +use std::path::Path; use std::time::Duration; use wiremock::MockServer; @@ -32,6 +33,16 @@ fn custom_tool_output_items(req: &ResponsesRequest, call_id: &str) -> Vec .clone() } +fn function_tool_output_items(req: &ResponsesRequest, call_id: &str) -> Vec { + match req.function_call_output(call_id).get("output") { + Some(Value::Array(items)) => items.clone(), + Some(Value::String(text)) => { + vec![serde_json::json!({ "type": "input_text", "text": text })] + } + _ => panic!("function tool output should be serialized as text or content items"), + } +} + fn text_item(items: &[Value], index: usize) -> &str { items[index] .get("text") @@ -39,6 +50,23 @@ fn text_item(items: &[Value], index: usize) -> &str { .expect("content item should be input_text") } +fn extract_running_session_id(text: &str) -> i32 { + text.strip_prefix("Script running with session ID ") + .and_then(|rest| rest.split('\n').next()) + .expect("running header should contain a session ID") + .parse() + .expect("session ID should parse as i32") +} + +fn wait_for_file_source(path: &Path) -> Result { + let quoted_path = shlex::try_join([path.to_string_lossy().as_ref()])?; + let command = format!("if [ -f {quoted_path} ]; then printf ready; fi"); + Ok(format!( + r#"while ((await exec_command({{ cmd: {command:?} }})).output !== "ready") {{ +}}"# + )) +} + fn custom_tool_output_body_and_success( req: &ResponsesRequest, call_id: &str, @@ -289,6 +317,922 @@ Error:\ boom\n Ok(()) } +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_can_yield_and_resume_with_exec_wait() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut builder = test_codex().with_config(move |config| { + let _ = config.features.enable(Feature::CodeMode); + }); + let test = builder.build(&server).await?; + let phase_2_gate = test.workspace_path("code-mode-phase-2.ready"); + let phase_3_gate = test.workspace_path("code-mode-phase-3.ready"); + let phase_2_wait = wait_for_file_source(&phase_2_gate)?; + let phase_3_wait = wait_for_file_source(&phase_3_gate)?; + + let code = format!( + r#" +import {{ output_text, set_yield_time }} from "@openai/code_mode"; +import {{ exec_command }} from "tools.js"; + +output_text("phase 1"); +set_yield_time(10); +{phase_2_wait} +output_text("phase 2"); +{phase_3_wait} +output_text("phase 3"); +"# + ); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call("call-1", "exec", &code), + ev_completed("resp-1"), + ]), + ) + .await; + let first_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "waiting"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("start the long exec").await?; + + let first_request = first_completion.single_request(); + let first_items = custom_tool_output_items(&first_request, "call-1"); + assert_eq!(first_items.len(), 2); + assert_regex_match( + concat!( + r"(?s)\A", + r"Script running with session ID \d+\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&first_items, 0), + ); + assert_eq!(text_item(&first_items, 1), "phase 1"); + let session_id = extract_running_session_id(text_item(&first_items, 0)); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-3"), + responses::ev_function_call( + "call-2", + "exec_wait", + &serde_json::to_string(&serde_json::json!({ + "session_id": session_id, + "yield_time_ms": 1_000, + }))?, + ), + ev_completed("resp-3"), + ]), + ) + .await; + let second_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-2", "still waiting"), + ev_completed("resp-4"), + ]), + ) + .await; + + fs::write(&phase_2_gate, "ready")?; + test.submit_turn("wait again").await?; + + let second_request = second_completion.single_request(); + let second_items = function_tool_output_items(&second_request, "call-2"); + assert_eq!(second_items.len(), 2); + assert_regex_match( + concat!( + r"(?s)\A", + r"Script running with session ID \d+\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&second_items, 0), + ); + assert_eq!( + extract_running_session_id(text_item(&second_items, 0)), + session_id + ); + assert_eq!(text_item(&second_items, 1), "phase 2"); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-5"), + responses::ev_function_call( + "call-3", + "exec_wait", + &serde_json::to_string(&serde_json::json!({ + "session_id": session_id, + "yield_time_ms": 1_000, + }))?, + ), + ev_completed("resp-5"), + ]), + ) + .await; + let third_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-3", "done"), + ev_completed("resp-6"), + ]), + ) + .await; + + fs::write(&phase_3_gate, "ready")?; + test.submit_turn("wait for completion").await?; + + let third_request = third_completion.single_request(); + let third_items = function_tool_output_items(&third_request, "call-3"); + assert_eq!(third_items.len(), 2); + assert_regex_match( + concat!( + r"(?s)\A", + r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&third_items, 0), + ); + assert_eq!(text_item(&third_items, 1), "phase 3"); + + Ok(()) +} + +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_can_run_multiple_yielded_sessions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut builder = test_codex().with_config(move |config| { + let _ = config.features.enable(Feature::CodeMode); + }); + let test = builder.build(&server).await?; + let session_a_gate = test.workspace_path("code-mode-session-a.ready"); + let session_b_gate = test.workspace_path("code-mode-session-b.ready"); + let session_a_wait = wait_for_file_source(&session_a_gate)?; + let session_b_wait = wait_for_file_source(&session_b_gate)?; + + let session_a_code = format!( + r#" +import {{ output_text, set_yield_time }} from "@openai/code_mode"; +import {{ exec_command }} from "tools.js"; + +output_text("session a start"); +set_yield_time(10); +{session_a_wait} +output_text("session a done"); +"# + ); + let session_b_code = format!( + r#" +import {{ output_text, set_yield_time }} from "@openai/code_mode"; +import {{ exec_command }} from "tools.js"; + +output_text("session b start"); +set_yield_time(10); +{session_b_wait} +output_text("session b done"); +"# + ); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call("call-1", "exec", &session_a_code), + ev_completed("resp-1"), + ]), + ) + .await; + let first_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "session a waiting"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("start session a").await?; + + let first_request = first_completion.single_request(); + let first_items = custom_tool_output_items(&first_request, "call-1"); + assert_eq!(first_items.len(), 2); + let session_a_id = extract_running_session_id(text_item(&first_items, 0)); + assert_eq!(text_item(&first_items, 1), "session a start"); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-3"), + ev_custom_tool_call("call-2", "exec", &session_b_code), + ev_completed("resp-3"), + ]), + ) + .await; + let second_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-2", "session b waiting"), + ev_completed("resp-4"), + ]), + ) + .await; + + test.submit_turn("start session b").await?; + + let second_request = second_completion.single_request(); + let second_items = custom_tool_output_items(&second_request, "call-2"); + assert_eq!(second_items.len(), 2); + let session_b_id = extract_running_session_id(text_item(&second_items, 0)); + assert_eq!(text_item(&second_items, 1), "session b start"); + assert_ne!(session_a_id, session_b_id); + + fs::write(&session_a_gate, "ready")?; + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-5"), + responses::ev_function_call( + "call-3", + "exec_wait", + &serde_json::to_string(&serde_json::json!({ + "session_id": session_a_id, + "yield_time_ms": 1_000, + }))?, + ), + ev_completed("resp-5"), + ]), + ) + .await; + let third_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-3", "session a done"), + ev_completed("resp-6"), + ]), + ) + .await; + + test.submit_turn("wait session a").await?; + + let third_request = third_completion.single_request(); + let third_items = function_tool_output_items(&third_request, "call-3"); + assert_eq!(third_items.len(), 2); + assert_regex_match( + concat!( + r"(?s)\A", + r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&third_items, 0), + ); + assert_eq!(text_item(&third_items, 1), "session a done"); + + fs::write(&session_b_gate, "ready")?; + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-7"), + responses::ev_function_call( + "call-4", + "exec_wait", + &serde_json::to_string(&serde_json::json!({ + "session_id": session_b_id, + "yield_time_ms": 1_000, + }))?, + ), + ev_completed("resp-7"), + ]), + ) + .await; + let fourth_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-4", "session b done"), + ev_completed("resp-8"), + ]), + ) + .await; + + test.submit_turn("wait session b").await?; + + let fourth_request = fourth_completion.single_request(); + let fourth_items = function_tool_output_items(&fourth_request, "call-4"); + assert_eq!(fourth_items.len(), 2); + assert_regex_match( + concat!( + r"(?s)\A", + r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&fourth_items, 0), + ); + assert_eq!(text_item(&fourth_items, 1), "session b done"); + + Ok(()) +} + +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exec_wait_can_terminate_and_continue() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut builder = test_codex().with_config(move |config| { + let _ = config.features.enable(Feature::CodeMode); + }); + let test = builder.build(&server).await?; + let termination_gate = test.workspace_path("code-mode-terminate.ready"); + let termination_wait = wait_for_file_source(&termination_gate)?; + + let code = format!( + r#" +import {{ output_text, set_yield_time }} from "@openai/code_mode"; +import {{ exec_command }} from "tools.js"; + +output_text("phase 1"); +set_yield_time(10); +{termination_wait} +output_text("phase 2"); +"# + ); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call("call-1", "exec", &code), + ev_completed("resp-1"), + ]), + ) + .await; + let first_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "waiting"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("start the long exec").await?; + + let first_request = first_completion.single_request(); + let first_items = custom_tool_output_items(&first_request, "call-1"); + assert_eq!(first_items.len(), 2); + let session_id = extract_running_session_id(text_item(&first_items, 0)); + assert_eq!(text_item(&first_items, 1), "phase 1"); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-3"), + responses::ev_function_call( + "call-2", + "exec_wait", + &serde_json::to_string(&serde_json::json!({ + "session_id": session_id, + "terminate": true, + }))?, + ), + ev_completed("resp-3"), + ]), + ) + .await; + let second_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-2", "terminated"), + ev_completed("resp-4"), + ]), + ) + .await; + + test.submit_turn("terminate it").await?; + + let second_request = second_completion.single_request(); + let second_items = function_tool_output_items(&second_request, "call-2"); + assert_eq!(second_items.len(), 1); + assert_regex_match( + concat!( + r"(?s)\A", + r"Script terminated\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&second_items, 0), + ); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-5"), + ev_custom_tool_call( + "call-3", + "exec", + r#" +import { output_text } from "@openai/code_mode"; + +output_text("after terminate"); +"#, + ), + ev_completed("resp-5"), + ]), + ) + .await; + let third_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-3", "done"), + ev_completed("resp-6"), + ]), + ) + .await; + + test.submit_turn("run another exec").await?; + + let third_request = third_completion.single_request(); + let third_items = custom_tool_output_items(&third_request, "call-3"); + assert_eq!(third_items.len(), 2); + assert_regex_match( + concat!( + r"(?s)\A", + r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&third_items, 0), + ); + assert_eq!(text_item(&third_items, 1), "after terminate"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exec_wait_returns_error_for_unknown_session() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut builder = test_codex().with_config(move |config| { + let _ = config.features.enable(Feature::CodeMode); + }); + let test = builder.build(&server).await?; + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + responses::ev_function_call( + "call-1", + "exec_wait", + &serde_json::to_string(&serde_json::json!({ + "session_id": 999_999, + "yield_time_ms": 1_000, + }))?, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("wait on an unknown exec session").await?; + + let request = completion.single_request(); + let (_, success) = request + .function_call_output_content_and_success("call-1") + .expect("function tool output should be present"); + assert_ne!(success, Some(true)); + + let items = function_tool_output_items(&request, "call-1"); + assert_eq!(items.len(), 2); + assert_regex_match( + concat!( + r"(?s)\A", + r"Script failed\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&items, 0), + ); + assert_eq!( + text_item(&items, 1), + "Script error:\nexec session 999999 not found" + ); + + Ok(()) +} + +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exec_wait_terminate_returns_completed_session_if_it_finished_in_background() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut builder = test_codex().with_config(move |config| { + let _ = config.features.enable(Feature::CodeMode); + }); + let test = builder.build(&server).await?; + let session_a_gate = test.workspace_path("code-mode-session-a-finished.ready"); + let session_b_gate = test.workspace_path("code-mode-session-b-blocked.ready"); + let session_a_done_marker = test.workspace_path("code-mode-session-a-done.txt"); + let session_a_wait = wait_for_file_source(&session_a_gate)?; + let session_b_wait = wait_for_file_source(&session_b_gate)?; + let session_a_done_marker_quoted = + shlex::try_join([session_a_done_marker.to_string_lossy().as_ref()])?; + let session_a_done_command = format!("printf done > {session_a_done_marker_quoted}"); + + let session_a_code = format!( + r#" +import {{ output_text, set_yield_time }} from "@openai/code_mode"; +import {{ exec_command }} from "tools.js"; + +output_text("session a start"); +set_yield_time(10); +{session_a_wait} +output_text("session a done"); +await exec_command({{ cmd: {session_a_done_command:?} }}); +"# + ); + let session_b_code = format!( + r#" +import {{ output_text, set_yield_time }} from "@openai/code_mode"; +import {{ exec_command }} from "tools.js"; + +output_text("session b start"); +set_yield_time(10); +{session_b_wait} +output_text("session b done"); +"# + ); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call("call-1", "exec", &session_a_code), + ev_completed("resp-1"), + ]), + ) + .await; + let first_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "session a waiting"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("start session a").await?; + + let first_request = first_completion.single_request(); + let first_items = custom_tool_output_items(&first_request, "call-1"); + assert_eq!(first_items.len(), 2); + let session_a_id = extract_running_session_id(text_item(&first_items, 0)); + assert_eq!(text_item(&first_items, 1), "session a start"); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-3"), + ev_custom_tool_call("call-2", "exec", &session_b_code), + ev_completed("resp-3"), + ]), + ) + .await; + let second_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-2", "session b waiting"), + ev_completed("resp-4"), + ]), + ) + .await; + + test.submit_turn("start session b").await?; + + let second_request = second_completion.single_request(); + let second_items = custom_tool_output_items(&second_request, "call-2"); + assert_eq!(second_items.len(), 2); + let session_b_id = extract_running_session_id(text_item(&second_items, 0)); + assert_eq!(text_item(&second_items, 1), "session b start"); + + fs::write(&session_a_gate, "ready")?; + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-5"), + responses::ev_function_call( + "call-3", + "exec_wait", + &serde_json::to_string(&serde_json::json!({ + "session_id": session_b_id, + "yield_time_ms": 1_000, + }))?, + ), + ev_completed("resp-5"), + ]), + ) + .await; + let third_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-3", "session b still waiting"), + ev_completed("resp-6"), + ]), + ) + .await; + + test.submit_turn("wait session b").await?; + + let third_request = third_completion.single_request(); + let third_items = function_tool_output_items(&third_request, "call-3"); + assert_eq!(third_items.len(), 1); + assert_regex_match( + concat!( + r"(?s)\A", + r"Script running with session ID \d+\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&third_items, 0), + ); + assert_eq!( + extract_running_session_id(text_item(&third_items, 0)), + session_b_id + ); + + for _ in 0..100 { + if session_a_done_marker.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!(session_a_done_marker.exists()); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-7"), + responses::ev_function_call( + "call-4", + "exec_wait", + &serde_json::to_string(&serde_json::json!({ + "session_id": session_a_id, + "terminate": true, + }))?, + ), + ev_completed("resp-7"), + ]), + ) + .await; + let fourth_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-4", "session a already done"), + ev_completed("resp-8"), + ]), + ) + .await; + + test.submit_turn("terminate session a").await?; + + let fourth_request = fourth_completion.single_request(); + let fourth_items = function_tool_output_items(&fourth_request, "call-4"); + match fourth_items.len() { + 1 => { + assert_regex_match( + concat!( + r"(?s)\A", + r"Script terminated\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&fourth_items, 0), + ); + } + 2 => { + assert_regex_match( + concat!( + r"(?s)\A", + r"Script (?:completed|terminated)\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&fourth_items, 0), + ); + assert_eq!(text_item(&fourth_items, 1), "session a done"); + } + other => panic!("unexpected number of content items: {other}"), + } + + Ok(()) +} + +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_background_keeps_running_on_later_turn_without_exec_wait() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut builder = test_codex().with_config(move |config| { + let _ = config.features.enable(Feature::CodeMode); + }); + let test = builder.build(&server).await?; + let resumed_file = test.workspace_path("code-mode-yield-resumed.txt"); + let resumed_file_quoted = shlex::try_join([resumed_file.to_string_lossy().as_ref()])?; + let write_file_command = format!("printf resumed > {resumed_file_quoted}"); + let wait_for_file_command = + format!("while [ ! -f {resumed_file_quoted} ]; do sleep 0.01; done; printf ready"); + let code = format!( + r#" +import {{ background, output_text }} from "@openai/code_mode"; +import {{ exec_command }} from "tools.js"; + +output_text("before yield"); +background(); +await exec_command({{ cmd: {write_file_command:?} }}); +output_text("after yield"); +"# + ); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call("call-1", "exec", &code), + ev_completed("resp-1"), + ]), + ) + .await; + let first_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "exec yielded"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("start yielded exec").await?; + + let first_request = first_completion.single_request(); + let first_items = custom_tool_output_items(&first_request, "call-1"); + assert_eq!(first_items.len(), 2); + assert_regex_match( + concat!( + r"(?s)\A", + r"Script running with session ID \d+\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&first_items, 0), + ); + assert_eq!(text_item(&first_items, 1), "before yield"); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-3"), + responses::ev_function_call( + "call-2", + "exec_command", + &serde_json::to_string(&serde_json::json!({ + "cmd": wait_for_file_command, + }))?, + ), + ev_completed("resp-3"), + ]), + ) + .await; + let second_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-2", "file appeared"), + ev_completed("resp-4"), + ]), + ) + .await; + + test.submit_turn("wait for resumed file").await?; + + let second_request = second_completion.single_request(); + assert!( + second_request + .function_call_output_text("call-2") + .is_some_and(|output| output.ends_with("ready")) + ); + assert_eq!(fs::read_to_string(&resumed_file)?, "resumed"); + + Ok(()) +} + +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exec_wait_uses_its_own_max_tokens_budget() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut builder = test_codex().with_config(move |config| { + let _ = config.features.enable(Feature::CodeMode); + }); + let test = builder.build(&server).await?; + let completion_gate = test.workspace_path("code-mode-max-tokens.ready"); + let completion_wait = wait_for_file_source(&completion_gate)?; + + let code = format!( + r#" +import {{ output_text, set_max_output_tokens_per_exec_call, set_yield_time }} from "@openai/code_mode"; +import {{ exec_command }} from "tools.js"; + +output_text("phase 1"); +set_max_output_tokens_per_exec_call(100); +set_yield_time(10); +{completion_wait} +output_text("token one token two token three token four token five token six token seven"); +"# + ); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call("call-1", "exec", &code), + ev_completed("resp-1"), + ]), + ) + .await; + let first_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "waiting"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("start the long exec").await?; + + let first_request = first_completion.single_request(); + let first_items = custom_tool_output_items(&first_request, "call-1"); + assert_eq!(first_items.len(), 2); + assert_eq!(text_item(&first_items, 1), "phase 1"); + let session_id = extract_running_session_id(text_item(&first_items, 0)); + + fs::write(&completion_gate, "ready")?; + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-3"), + responses::ev_function_call( + "call-2", + "exec_wait", + &serde_json::to_string(&serde_json::json!({ + "session_id": session_id, + "yield_time_ms": 1_000, + "max_tokens": 6, + }))?, + ), + ev_completed("resp-3"), + ]), + ) + .await; + let second_completion = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-2", "done"), + ev_completed("resp-4"), + ]), + ) + .await; + + test.submit_turn("wait for completion").await?; + + let second_request = second_completion.single_request(); + let second_items = function_tool_output_items(&second_request, "call-2"); + assert_eq!(second_items.len(), 2); + assert_regex_match( + concat!( + r"(?s)\A", + r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z" + ), + text_item(&second_items, 0), + ); + let expected_pattern = r#"(?sx) +\A +Total\ output\ lines:\ 1\n +\n +.*…\d+\ tokens\ truncated….* +\z +"#; + assert_regex_match(expected_pattern, text_item(&second_items, 1)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_can_output_serialized_text_via_openai_code_mode_module() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 14120bad5a..079c49797f 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -687,7 +687,7 @@ async fn resume_conversation( let auth_manager = codex_core::test_support::auth_manager_from_auth( codex_core::CodexAuth::from_api_key("dummy"), ); - Box::pin(manager.resume_thread_from_rollout(config.clone(), path, auth_manager)) + Box::pin(manager.resume_thread_from_rollout(config.clone(), path, auth_manager, None)) .await .expect("resume conversation") .thread @@ -700,7 +700,7 @@ async fn fork_thread( path: std::path::PathBuf, nth_user_message: usize, ) -> Arc { - Box::pin(manager.fork_thread(nth_user_message, config.clone(), path, false)) + Box::pin(manager.fork_thread(nth_user_message, config.clone(), path, false, None)) .await .expect("fork conversation") .thread diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 96457065de..96fef440c8 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -110,7 +110,7 @@ async fn fork_thread_twice_drops_to_first_message() { thread: codex_fork1, .. } = thread_manager - .fork_thread(1, config_for_fork.clone(), base_path.clone(), false) + .fork_thread(1, config_for_fork.clone(), base_path.clone(), false, None) .await .expect("fork 1"); @@ -129,7 +129,7 @@ async fn fork_thread_twice_drops_to_first_message() { thread: codex_fork2, .. } = thread_manager - .fork_thread(0, config_for_fork.clone(), fork1_path.clone(), false) + .fork_thread(0, config_for_fork.clone(), fork1_path.clone(), false, None) .await .expect("fork 2"); diff --git a/codex-rs/core/tests/suite/js_repl.rs b/codex-rs/core/tests/suite/js_repl.rs index 7619cf7131..4ebfb52cb6 100644 --- a/codex-rs/core/tests/suite/js_repl.rs +++ b/codex-rs/core/tests/suite/js_repl.rs @@ -659,6 +659,34 @@ async fn js_repl_does_not_expose_process_global() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_exposes_codex_path_helpers() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mock = run_js_repl_turn( + &server, + "check codex path helpers", + &[( + "call-1", + "console.log(`cwd:${typeof codex.cwd}:${codex.cwd.length > 0}`); console.log(`home:${codex.homeDir === null || typeof codex.homeDir === \"string\"}`);", + )], + ) + .await?; + + let req = mock.single_request(); + let (output, success) = custom_tool_output_text_and_success(&req, "call-1"); + assert_ne!( + success, + Some(false), + "js_repl call failed unexpectedly: {output}" + ); + assert!(output.contains("cwd:string:true")); + assert!(output.contains("home:true")); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn js_repl_blocks_sensitive_builtin_imports() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 8dfe2b5e6b..ceaf65aa61 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -416,7 +416,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { fork_config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted); let forked = initial .thread_manager - .fork_thread(usize::MAX, fork_config, rollout_path, false) + .fork_thread(usize::MAX, fork_config, rollout_path, false, None) .await?; forked .thread diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index b797ac3f2a..802e4d68d4 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -277,7 +277,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { assert!( request_tools .iter() - .any(|name| name == "mcp__codex_apps__calendar_create_event"), + .any(|name| name == "mcp__codex_apps__google-calendar-create-event"), "expected plugin app tools to become visible for this turn: {request_tools:?}" ); let echo_description = tool_description(&request_body, "mcp__sample__echo") @@ -286,9 +286,11 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { echo_description.contains("This tool is part of plugin `sample`."), "expected plugin MCP provenance in tool description: {echo_description:?}" ); - let calendar_description = - tool_description(&request_body, "mcp__codex_apps__calendar_create_event") - .expect("plugin app tool description should be present"); + let calendar_description = tool_description( + &request_body, + "mcp__codex_apps__google-calendar-create-event", + ) + .expect("plugin app tool description should be present"); assert!( calendar_description.contains("This tool is part of plugin `sample`."), "expected plugin app provenance in tool description: {calendar_description:?}" diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index fcf2bf8e0f..b1e6a694ca 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -98,7 +98,7 @@ async fn emits_warning_when_resumed_model_differs() { thread: conversation, .. } = thread_manager - .resume_thread_with_history(config, initial_history, auth_manager, false) + .resume_thread_with_history(config, initial_history, auth_manager, false, None) .await .expect("resume conversation"); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 13fb666eef..7b0a72b178 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -1,19 +1,13 @@ #![cfg(not(target_os = "windows"))] #![allow(clippy::unwrap_used, clippy::expect_used)] -use std::sync::Arc; -use std::time::Duration; - use anyhow::Result; use codex_core::CodexAuth; -use codex_core::CodexThread; -use codex_core::NewThread; use codex_core::config::Config; -use codex_core::config::types::McpServerConfig; -use codex_core::config::types::McpServerTransportConfig; use codex_core::features::Feature; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; @@ -21,14 +15,13 @@ use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_tool_search_call; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; -use core_test_support::stdio_server_bin; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -36,17 +29,14 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; -const SEARCH_TOOL_INSTRUCTION_SNIPPETS: [&str; 2] = [ - "MCP tools of the apps (Calendar) are hidden until you search for them with this tool", - "Matching tools are added to available `tools` and available for the remainder of the current session/thread.", +const SEARCH_TOOL_DESCRIPTION_SNIPPETS: [&str; 1] = [ + "Tools of the apps (Calendar) are hidden until you search for them with this tool (`tool_search`).", ]; -const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25"; -const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event"; -const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar_list_events"; -const RMCP_ECHO_TOOL: &str = "mcp__rmcp__echo"; -const RMCP_IMAGE_TOOL: &str = "mcp__rmcp__image"; -const CALENDAR_CREATE_QUERY: &str = "create calendar event"; -const CALENDAR_LIST_QUERY: &str = "list calendar events"; +const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; +const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar-create-event"; +const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar-list-events"; +const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar"; +const SEARCH_CALENDAR_CREATE_TOOL: &str = "-create-event"; fn tool_names(body: &Value) -> Vec { body.get("tools") @@ -65,12 +55,12 @@ fn tool_names(body: &Value) -> Vec { .unwrap_or_default() } -fn search_tool_description(body: &Value) -> Option { +fn tool_search_description(body: &Value) -> Option { body.get("tools") .and_then(Value::as_array) .and_then(|tools| { tools.iter().find_map(|tool| { - if tool.get("name").and_then(Value::as_str) == Some(SEARCH_TOOL_BM25_TOOL_NAME) { + if tool.get("type").and_then(Value::as_str) == Some(TOOL_SEARCH_TOOL_NAME) { tool.get("description") .and_then(Value::as_str) .map(str::to_string) @@ -81,69 +71,19 @@ fn search_tool_description(body: &Value) -> Option { }) } -fn search_tool_output_payload(request: &ResponsesRequest, call_id: &str) -> Value { - let (content, _success) = request - .function_call_output_content_and_success(call_id) - .unwrap_or_else(|| { - panic!("{SEARCH_TOOL_BM25_TOOL_NAME} function_call_output should be present") - }); - let content = content - .unwrap_or_else(|| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} output should include content")); - serde_json::from_str(&content) - .unwrap_or_else(|_| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} content should be valid JSON")) +fn tool_search_output_item(request: &ResponsesRequest, call_id: &str) -> Value { + request.tool_search_output(call_id) } -fn active_selected_tools(payload: &Value) -> Vec { - payload - .get("active_selected_tools") - .and_then(Value::as_array) - .expect("active_selected_tools should be an array") - .iter() - .map(|value| { - value - .as_str() - .expect("active_selected_tools entries should be strings") - .to_string() - }) - .collect() -} - -fn search_result_tools(payload: &Value) -> Vec<&Value> { - payload +fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec { + tool_search_output_item(request, call_id) .get("tools") .and_then(Value::as_array) - .map(Vec::as_slice) + .cloned() .unwrap_or_default() - .iter() - .collect() } -fn rmcp_server_config(command: String) -> McpServerConfig { - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command, - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: Some(Duration::from_secs(10)), - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - } -} - -fn configure_apps_with_optional_rmcp( - config: &mut Config, - apps_base_url: &str, - rmcp_server_bin: Option, -) { +fn configure_apps(config: &mut Config, apps_base_url: &str) { config .features .enable(Feature::Apps) @@ -153,40 +93,16 @@ fn configure_apps_with_optional_rmcp( .disable(Feature::AppsMcpGateway) .expect("test config should allow feature update"); config.chatgpt_base_url = apps_base_url.to_string(); - if let Some(command) = rmcp_server_bin { - let mut servers = config.mcp_servers.get().clone(); - servers.insert("rmcp".to_string(), rmcp_server_config(command)); - config - .mcp_servers - .set(servers) - .expect("test mcp servers should accept any configuration"); - } } -fn configured_builder(apps_base_url: String, rmcp_server_bin: Option) -> TestCodexBuilder { +fn configured_builder(apps_base_url: String) -> TestCodexBuilder { test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(move |config| { - configure_apps_with_optional_rmcp(config, apps_base_url.as_str(), rmcp_server_bin); - }) -} - -async fn submit_user_input(thread: &Arc, text: &str) -> Result<()> { - thread - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - }) - .await?; - wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; - Ok(()) + .with_config(move |config| configure_apps(config, apps_base_url.as_str())) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_flag_adds_tool() -> Result<()> { +async fn search_tool_flag_adds_tool_search() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -201,7 +117,7 @@ async fn search_tool_flag_adds_tool() -> Result<()> { ) .await; - let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -212,17 +128,39 @@ async fn search_tool_flag_adds_tool() -> Result<()> { .await?; let body = mock.single_request().body_json(); - let tools = tool_names(&body); - assert!( - tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME), - "tools list should include {SEARCH_TOOL_BM25_TOOL_NAME} when enabled: {tools:?}" + let tools = body + .get("tools") + .and_then(Value::as_array) + .expect("tools array should exist"); + let tool_search = tools + .iter() + .find(|tool| tool.get("type").and_then(Value::as_str) == Some(TOOL_SEARCH_TOOL_NAME)) + .cloned() + .expect("tool_search should be present"); + + assert_eq!( + tool_search, + json!({ + "type": "tool_search", + "execution": "client", + "description": tool_search["description"].as_str().expect("description should exist"), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query for apps tools."}, + "limit": {"type": "number", "description": "Maximum number of tools to return (defaults to 8)."}, + }, + "required": ["query"], + "additionalProperties": false, + } + }) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_flag_adds_tool_for_api_key_auth() -> Result<()> { +async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -239,9 +177,7 @@ async fn search_tool_flag_adds_tool_for_api_key_auth() -> Result<()> { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) - .with_config(move |config| { - configure_apps_with_optional_rmcp(config, apps_server.chatgpt_base_url.as_str(), None); - }); + .with_config(move |config| configure_apps(config, apps_server.chatgpt_base_url.as_str())); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -254,8 +190,8 @@ async fn search_tool_flag_adds_tool_for_api_key_auth() -> Result<()> { let body = mock.single_request().body_json(); let tools = tool_names(&body); assert!( - tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME), - "tools list should include {SEARCH_TOOL_BM25_TOOL_NAME} for API key auth when Apps is enabled: {tools:?}" + !tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), + "tools list should not include {TOOL_SEARCH_TOOL_NAME} for API key auth: {tools:?}" ); Ok(()) @@ -267,17 +203,17 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result let server = start_mock_server().await; let apps_server = AppsTestServer::mount(&server).await?; - let mock = mount_sse_sequence( + let mock = mount_sse_once( &server, - vec![sse(vec![ + sse(vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "done"), ev_completed("resp-1"), - ])], + ]), ) .await; - let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -288,39 +224,38 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result .await?; let body = mock.single_request().body_json(); - let description = search_tool_description(&body).expect("search tool description should exist"); + let description = tool_search_description(&body).expect("tool_search description should exist"); assert!( - SEARCH_TOOL_INSTRUCTION_SNIPPETS + SEARCH_TOOL_DESCRIPTION_SNIPPETS .iter() .all(|snippet| description.contains(snippet)), - "search tool description should include search tool workflow: {description:?}" + "tool_search description should include the updated workflow: {description:?}" + ); + assert!( + !description.contains("remainder of the current session/thread"), + "tool_search description should not mention legacy client-side persistence: {description:?}" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_hides_apps_tools_without_search_but_keeps_non_app_tools_visible() -> Result<()> -{ +async fn search_tool_hides_apps_tools_without_search() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let apps_server = AppsTestServer::mount(&server).await?; - let mock = mount_sse_sequence( + let mock = mount_sse_once( &server, - vec![sse(vec![ + sse(vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "done"), ev_completed("resp-1"), - ])], + ]), ) .await; - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -332,26 +267,9 @@ async fn search_tool_hides_apps_tools_without_search_but_keeps_non_app_tools_vis let body = mock.single_request().body_json(); let tools = tool_names(&body); - assert!( - tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME), - "tools list should include {SEARCH_TOOL_BM25_TOOL_NAME}: {tools:?}" - ); - assert!( - tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should remain visible in Apps mode: {tools:?}" - ); - assert!( - tools.iter().any(|name| name == RMCP_IMAGE_TOOL), - "non-app MCP tools should remain visible in Apps mode: {tools:?}" - ); - assert!( - !tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), - "apps tools should stay hidden before search/mention: {tools:?}" - ); - assert!( - !tools.iter().any(|name| name == CALENDAR_LIST_TOOL), - "apps tools should stay hidden before search/mention: {tools:?}" - ); + assert!(tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); + assert!(!tools.iter().any(|name| name == CALENDAR_CREATE_TOOL)); + assert!(!tools.iter().any(|name| name == CALENDAR_LIST_TOOL)); Ok(()) } @@ -362,21 +280,17 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()> let server = start_mock_server().await; let apps_server = AppsTestServer::mount(&server).await?; - let mock = mount_sse_sequence( + let mock = mount_sse_once( &server, - vec![sse(vec![ + sse(vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "done"), ev_completed("resp-1"), - ])], + ]), ) .await; - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -388,829 +302,178 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()> let body = mock.single_request().body_json(); let tools = tool_names(&body); - assert!( - tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app tools should remain visible: {tools:?}" - ); assert!( tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), - "apps tools should be available after explicit app mention: {tools:?}" + "expected explicit app mention to expose create tool, got tools: {tools:?}" ); assert!( tools.iter().any(|name| name == CALENDAR_LIST_TOOL), - "apps tools should be available after explicit app mention: {tools:?}" + "expected explicit app mention to expose list tool, got tools: {tools:?}" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_results_match_plugin_names_and_annotate_descriptions() -> Result<()> { +async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?; - let call_id = "tool-search"; - let args = json!({ - "query": "sample", - "limit": 2, - }); + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "tool-search-1"; let mock = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call( + ev_tool_search_call( call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&args)?, + &json!({ + "query": "create calendar event", + "limit": 1, + }), ), ev_completed("resp-1"), ]), sse(vec![ - ev_assistant_message("msg-1", "done"), + ev_response_created("resp-2"), + json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "call_id": "calendar-call-1", + "name": SEARCH_CALENDAR_CREATE_TOOL, + "namespace": SEARCH_CALENDAR_NAMESPACE, + "arguments": serde_json::to_string(&json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z" + })).expect("serialize calendar args") + } + }), ev_completed("resp-2"), ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-3"), + ]), ], ) .await; - let codex_home = Arc::new(tempfile::TempDir::new()?); - let plugin_root = codex_home.path().join("plugins/cache/test/sample/local"); - std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); - std::fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ) - .expect("write plugin manifest"); - std::fs::write( - plugin_root.join(".app.json"), - r#"{ - "apps": { - "calendar": { - "id": "calendar" - } - } -}"#, - ) - .expect("write plugin app config"); - std::fs::write( - codex_home.path().join("config.toml"), - "[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n", - ) - .expect("write config"); - - let mut builder = - configured_builder(apps_server.chatgpt_base_url.clone(), None).with_home(codex_home); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Find the calendar create tool".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; - test.submit_turn_with_policies( - "find sample plugin tools", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; + let EventMsg::McpToolCallEnd(end) = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::McpToolCallEnd(_)) + }) + .await + else { + unreachable!("event guard guarantees McpToolCallEnd"); + }; + assert_eq!(end.call_id, "calendar-call-1"); + assert_eq!( + end.invocation, + McpInvocation { + server: "codex_apps".to_string(), + tool: "calendar_create_event".to_string(), + arguments: Some(json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z" + })), + } + ); + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; let requests = mock.requests(); - assert_eq!( - requests.len(), - 2, - "expected 2 requests, got {}", - requests.len() - ); + assert_eq!(requests.len(), 3); - let search_output_payload = search_tool_output_payload(&requests[1], call_id); - let result_tools = search_result_tools(&search_output_payload); - assert_eq!(result_tools.len(), 2, "expected 2 search results"); + let first_request_tools = tool_names(&requests[0].body_json()); assert!( - result_tools.iter().all(|tool| { - tool.get("description") - .and_then(Value::as_str) - .is_some_and(|description| { - description.contains("This tool is part of plugin `sample`.") - }) - }), - "expected plugin provenance in search result descriptions: {search_output_payload:?}" - ); - assert!( - result_tools + first_request_tools .iter() - .any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_CREATE_TOOL) }), - "expected calendar create tool in search results: {search_output_payload:?}" + .any(|name| name == TOOL_SEARCH_TOOL_NAME), + "first request should advertise tool_search: {first_request_tools:?}" ); assert!( - result_tools - .iter() - .any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_LIST_TOOL) }), - "expected calendar list tool in search results: {search_output_payload:?}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_persists_across_turns() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let call_id = "tool-search"; - let args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_assistant_message("msg-2", "done again"), - ev_completed("resp-3"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let test = builder.build(&server).await?; - - test.submit_turn_with_policies( - "find calendar create tool", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - test.submit_turn_with_policies( - "hello again", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 3, - "expected 3 requests, got {}", - requests.len() - ); - - let first_tools = tool_names(&requests[0].body_json()); - assert!( - first_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should be available before search: {first_tools:?}" - ); - assert!( - !first_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), - "apps tools should not be visible before search: {first_tools:?}" - ); - - let search_output_payload = search_tool_output_payload(&requests[1], call_id); - assert!( - search_output_payload.get("selected_tools").is_none(), - "selected_tools should not be returned: {search_output_payload:?}" - ); - for tool in search_result_tools(&search_output_payload) { - assert_eq!( - tool.get("server").and_then(Value::as_str), - Some("codex_apps"), - "search results should only include codex_apps tools: {search_output_payload:?}" - ); - } - - let selected_tools = active_selected_tools(&search_output_payload); - assert!( - selected_tools - .iter() - .any(|tool| tool == CALENDAR_CREATE_TOOL), - "calendar create tool should be selected: {search_output_payload:?}" - ); - assert!( - !selected_tools - .iter() - .any(|tool_name| tool_name.starts_with("mcp__rmcp__")), - "search should not add rmcp tools to active selection: {search_output_payload:?}" - ); - - let second_tools = tool_names(&requests[1].body_json()); - assert!( - second_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should remain visible after search: {second_tools:?}" - ); - for selected_tool in &selected_tools { - assert!( - second_tools.iter().any(|name| name == selected_tool), - "follow-up request should include selected tool {selected_tool:?}: {second_tools:?}" - ); - } - - let third_tools = tool_names(&requests[2].body_json()); - assert!( - third_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should remain visible on later turns: {third_tools:?}" - ); - for selected_tool in &selected_tools { - assert!( - third_tools.iter().any(|name| name == selected_tool), - "subsequent turn should include selected tool {selected_tool:?}: {third_tools:?}" - ); - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_unions_results_within_turn() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let first_call_id = "tool-search-create"; - let second_call_id = "tool-search-list"; - let first_args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let second_args = json!({ - "query": CALENDAR_LIST_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - first_call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&first_args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_function_call( - second_call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&second_args)?, - ), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-3"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let test = builder.build(&server).await?; - - test.submit_turn_with_policies( - "find create and list calendar tools", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 3, - "expected 3 requests, got {}", - requests.len() - ); - - let first_tools = tool_names(&requests[0].body_json()); - assert!( - first_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should be visible before search: {first_tools:?}" - ); - assert!( - !first_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), - "apps tools should be hidden before search: {first_tools:?}" - ); - assert!( - !first_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), - "apps tools should be hidden before search: {first_tools:?}" - ); - - let second_search_payload = search_tool_output_payload(&requests[2], second_call_id); - assert!( - second_search_payload.get("selected_tools").is_none(), - "selected_tools should not be returned: {second_search_payload:?}" - ); - for tool in search_result_tools(&second_search_payload) { - assert_eq!( - tool.get("server").and_then(Value::as_str), - Some("codex_apps"), - "search results should only include codex_apps tools: {second_search_payload:?}" - ); - } - - let selected_tools = active_selected_tools(&second_search_payload); - assert_eq!( - selected_tools, - vec![ - CALENDAR_CREATE_TOOL.to_string(), - CALENDAR_LIST_TOOL.to_string(), - ], - "two searches in one turn should union selected apps tools" - ); - - let third_tools = tool_names(&requests[2].body_json()); - assert!( - third_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app MCP tools should remain visible after repeated search: {third_tools:?}" - ); - assert!( - third_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), - "calendar create should be available after repeated search: {third_tools:?}" - ); - assert!( - third_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), - "calendar list should be available after repeated search: {third_tools:?}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_restores_when_resumed() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let call_id = "tool-search"; - let args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_assistant_message("msg-2", "resumed done"), - ev_completed("resp-3"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin.clone()), - ); - let test = builder.build(&server).await?; - - let home = test.home.clone(); - let rollout_path = test - .session_configured - .rollout_path - .clone() - .expect("rollout path should be available for resume"); - - test.submit_turn_with_policies( - "find calendar tools", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 2, - "expected 2 requests after initial turn, got {}", - requests.len() - ); - let search_output_payload = search_tool_output_payload(&requests[1], call_id); - let selected_tools = active_selected_tools(&search_output_payload); - assert!( - selected_tools + !first_request_tools .iter() .any(|name| name == CALENDAR_CREATE_TOOL), - "search should select calendar create before resume: {search_output_payload:?}" + "app tools should still be hidden before search: {first_request_tools:?}" ); - let mut resume_builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let resumed = resume_builder.resume(&server, home, rollout_path).await?; - resumed - .submit_turn_with_policies( - "hello after resume", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); + let output_item = tool_search_output_item(&requests[1], call_id); assert_eq!( - requests.len(), - 3, - "expected 3 requests after resumed turn, got {}", - requests.len() + output_item.get("status").and_then(Value::as_str), + Some("completed") ); - let resumed_tools = tool_names(&requests[2].body_json()); + assert_eq!( + output_item.get("execution").and_then(Value::as_str), + Some("client") + ); + + let tools = tool_search_output_tools(&requests[1], call_id); + assert_eq!( + tools, + vec![json!({ + "type": "namespace", + "name": SEARCH_CALENDAR_NAMESPACE, + "description": "Plan events and manage your calendar.", + "tools": [ + { + "type": "function", + "name": SEARCH_CALENDAR_CREATE_TOOL, + "description": "Create a calendar event.", + "strict": false, + "defer_loading": true, + "parameters": { + "type": "object", + "properties": { + "starts_at": {"type": "string"}, + "timezone": {"type": "string"}, + "title": {"type": "string"}, + }, + "required": ["title", "starts_at"], + "additionalProperties": false, + } + } + ] + })] + ); + + let second_request_tools = tool_names(&requests[1].body_json()); assert!( - resumed_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app tools should remain visible after resume: {resumed_tools:?}" - ); - for selected_tool in &selected_tools { - assert!( - resumed_tools.iter().any(|name| name == selected_tool), - "resumed request should include restored selected tool {selected_tool:?}: {resumed_tools:?}" - ); - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_union_restores_when_resumed_after_multiple_search_calls() --> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let first_call_id = "tool-search-create"; - let second_call_id = "tool-search-list"; - let first_args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let second_args = json!({ - "query": CALENDAR_LIST_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - first_call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&first_args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "first search done"), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_function_call( - second_call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&second_args)?, - ), - ev_completed("resp-3"), - ]), - sse(vec![ - ev_response_created("resp-4"), - ev_assistant_message("msg-2", "second search done"), - ev_completed("resp-4"), - ]), - sse(vec![ - ev_response_created("resp-5"), - ev_assistant_message("msg-3", "resumed done"), - ev_completed("resp-5"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin.clone()), - ); - let test = builder.build(&server).await?; - - let home = test.home.clone(); - let rollout_path = test - .session_configured - .rollout_path - .clone() - .expect("rollout path should be available for resume"); - - test.submit_turn_with_policies( - "find create calendar tool", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - test.submit_turn_with_policies( - "find list calendar tool", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 4, - "expected 4 requests before resume, got {}", - requests.len() - ); - - let first_search_payload = search_tool_output_payload(&requests[1], first_call_id); - let first_result_tools = search_result_tools(&first_search_payload); - assert_eq!( - first_result_tools.len(), - 1, - "first search should return exactly one tool: {first_search_payload:?}" - ); - assert_eq!( - first_result_tools[0].get("name").and_then(Value::as_str), - Some(CALENDAR_CREATE_TOOL), - "first search should return calendar create tool: {first_search_payload:?}" - ); - let first_selected_tools = active_selected_tools(&first_search_payload); - assert_eq!( - first_selected_tools, - vec![CALENDAR_CREATE_TOOL.to_string()], - "first search should only select create tool: {first_search_payload:?}" - ); - - let second_search_payload = search_tool_output_payload(&requests[3], second_call_id); - let second_result_tools = search_result_tools(&second_search_payload); - assert_eq!( - second_result_tools.len(), - 1, - "second search should return exactly one tool: {second_search_payload:?}" - ); - assert_eq!( - second_result_tools[0].get("name").and_then(Value::as_str), - Some(CALENDAR_LIST_TOOL), - "second search should return calendar list tool: {second_search_payload:?}" - ); - let second_selected_tools = active_selected_tools(&second_search_payload); - assert_eq!( - second_selected_tools, - vec![ - CALENDAR_CREATE_TOOL.to_string(), - CALENDAR_LIST_TOOL.to_string(), - ], - "multiple searches should persist union before resume: {second_search_payload:?}" - ); - - let mut resume_builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let resumed = resume_builder.resume(&server, home, rollout_path).await?; - resumed - .submit_turn_with_policies( - "hello after resume with union", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 5, - "expected 5 requests after resumed turn, got {}", - requests.len() - ); - - let resumed_tools = tool_names(&requests[4].body_json()); - assert!( - resumed_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app tools should remain visible after resume: {resumed_tools:?}" - ); - assert!( - resumed_tools + !second_request_tools .iter() .any(|name| name == CALENDAR_CREATE_TOOL), - "resumed turn should restore calendar create tool: {resumed_tools:?}" - ); - assert!( - resumed_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), - "resumed turn should restore calendar list tool: {resumed_tools:?}" + "follow-up request should rely on tool_search_output history, not tool injection: {second_request_tools:?}" ); - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_restores_when_forked_with_full_history() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let call_id = "tool-search"; - let args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_assistant_message("msg-2", "forked done"), - ev_completed("resp-3"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let test = builder.build(&server).await?; - - test.submit_turn_with_policies( - "find calendar tools", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); + let output_item = requests[2].function_call_output("calendar-call-1"); assert_eq!( - requests.len(), - 2, - "expected 2 requests after initial turn, got {}", - requests.len() + output_item.get("call_id").and_then(Value::as_str), + Some("calendar-call-1") ); - let search_output_payload = search_tool_output_payload(&requests[1], call_id); - let selected_tools = active_selected_tools(&search_output_payload); + + let third_request_tools = tool_names(&requests[2].body_json()); assert!( - selected_tools + !third_request_tools .iter() .any(|name| name == CALENDAR_CREATE_TOOL), - "search should select calendar create: {search_output_payload:?}" - ); - - let rollout_path = test - .codex - .rollout_path() - .expect("rollout path should exist for fork"); - let NewThread { thread: forked, .. } = test - .thread_manager - .fork_thread(usize::MAX, test.config.clone(), rollout_path, false) - .await?; - submit_user_input(&forked, "hello after fork").await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 3, - "expected 3 requests after forked turn, got {}", - requests.len() - ); - let forked_tools = tool_names(&requests[2].body_json()); - assert!( - forked_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app tools should remain visible in forked thread: {forked_tools:?}" - ); - for selected_tool in &selected_tools { - assert!( - forked_tools.iter().any(|name| name == selected_tool), - "forked request should include restored selected tool {selected_tool:?}: {forked_tools:?}" - ); - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_drops_when_fork_excludes_search_turn() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; - let call_id = "tool-search"; - let args = json!({ - "query": CALENDAR_CREATE_QUERY, - "limit": 1, - }); - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - call_id, - SEARCH_TOOL_BM25_TOOL_NAME, - &serde_json::to_string(&args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_assistant_message("msg-2", "forked done"), - ev_completed("resp-3"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = configured_builder( - apps_server.chatgpt_base_url.clone(), - Some(rmcp_test_server_bin), - ); - let test = builder.build(&server).await?; - - test.submit_turn_with_policies( - "find calendar tools", - AskForApproval::Never, - SandboxPolicy::DangerFullAccess, - ) - .await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 2, - "expected 2 requests after initial turn, got {}", - requests.len() - ); - let search_output_payload = search_tool_output_payload(&requests[1], call_id); - let selected_tools = active_selected_tools(&search_output_payload); - assert!( - !selected_tools.is_empty(), - "search turn should produce selected tools: {search_output_payload:?}" - ); - - let rollout_path = test - .codex - .rollout_path() - .expect("rollout path should exist for fork"); - let NewThread { thread: forked, .. } = test - .thread_manager - .fork_thread(0, test.config.clone(), rollout_path, false) - .await?; - submit_user_input(&forked, "hello after fork").await?; - - let requests = mock.requests(); - assert_eq!( - requests.len(), - 3, - "expected 3 requests after forked turn, got {}", - requests.len() - ); - let forked_tools = tool_names(&requests[2].body_json()); - assert!( - forked_tools.iter().any(|name| name == RMCP_ECHO_TOOL), - "non-app tools should remain visible in forked thread: {forked_tools:?}" - ); - assert!( - !forked_tools - .iter() - .any(|name| name.starts_with("mcp__codex_apps__")), - "forked history without search turn should not restore apps tools: {forked_tools:?}" + "post-tool follow-up should still rely on tool_search_output history, not tool injection: {third_request_tools:?}" ); Ok(()) diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index ad822805a8..117d1d9ef2 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -180,6 +180,24 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> !description.contains("Hidden Model"), "hidden picker model should be omitted from spawn_agent description: {description:?}" ); + assert!( + description.contains( + "Only use `spawn_agent` if and only if the user explicitly asks for sub-agents, delegation, or parallel agent work." + ), + "expected explicit authorization rule in spawn_agent description: {description:?}" + ); + assert!( + description.contains( + "Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn." + ), + "expected non-authorization clarification in spawn_agent description: {description:?}" + ); + assert!( + description.contains( + "Agent-role guidance below only helps choose which agent to use after spawning is already authorized; it never authorizes spawning by itself." + ), + "expected agent-role clarification in spawn_agent description: {description:?}" + ); Ok(()) } diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs index 9269ed262a..94d7b51838 100644 --- a/codex-rs/core/tests/suite/unstable_features_warning.rs +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -42,7 +42,7 @@ async fn emits_warning_when_unstable_features_enabled_via_config() { thread: conversation, .. } = thread_manager - .resume_thread_with_history(config, InitialHistory::New, auth_manager, false) + .resume_thread_with_history(config, InitialHistory::New, auth_manager, false, None) .await .expect("spawn conversation"); @@ -83,7 +83,7 @@ async fn suppresses_warning_when_configured() { thread: conversation, .. } = thread_manager - .resume_thread_with_history(config, InitialHistory::New, auth_manager, false) + .resume_thread_with_history(config, InitialHistory::New, auth_manager, false, None) .await .expect("spawn conversation"); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 7a58513731..3bf8627b5d 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -296,7 +296,8 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn view_image_tool_can_preserve_original_resolution_on_gpt5_3_codex() -> anyhow::Result<()> { +async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5_3_codex() +-> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -326,7 +327,7 @@ async fn view_image_tool_can_preserve_original_resolution_on_gpt5_3_codex() -> a image.save(&abs_path)?; let call_id = "view-image-original"; - let arguments = serde_json::json!({ "path": rel_path }).to_string(); + let arguments = serde_json::json!({ "path": rel_path, "detail": "original" }).to_string(); let first_response = sse(vec![ ev_response_created("resp-1"), @@ -400,7 +401,191 @@ async fn view_image_tool_can_preserve_original_resolution_on_gpt5_3_codex() -> a } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn view_image_tool_keeps_legacy_behavior_below_gpt5_3_codex() -> anyhow::Result<()> { +async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_model("gpt-5.3-codex") + .with_config(|config| { + config + .features + .enable(Feature::ImageDetailOriginal) + .expect("test config should allow feature update"); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let rel_path = "assets/unsupported-detail.png"; + let abs_path = cwd.path().join(rel_path); + if let Some(parent) = abs_path.parent() { + std::fs::create_dir_all(parent)?; + } + let image = ImageBuffer::from_pixel(256, 128, Rgba([0u8, 80, 255, 255])); + image.save(&abs_path)?; + + let call_id = "view-image-unsupported-detail"; + let arguments = serde_json::json!({ "path": rel_path, "detail": "low" }).to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "view_image", &arguments), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please attach the image at low detail".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + service_tier: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let req = mock.single_request(); + let body_with_tool_output = req.body_json(); + let output_text = req + .function_call_output_content_and_success(call_id) + .and_then(|(content, _)| content) + .expect("output text present"); + assert_eq!( + output_text, + "view_image.detail only supports `original`; omit `detail` for default resized behavior, got `low`" + ); + + assert!( + find_image_message(&body_with_tool_output).is_none(), + "unsupported detail values should not produce an input_image message" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_model("gpt-5.3-codex") + .with_config(|config| { + config + .features + .enable(Feature::ImageDetailOriginal) + .expect("test config should allow feature update"); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let rel_path = "assets/null-detail.png"; + let abs_path = cwd.path().join(rel_path); + if let Some(parent) = abs_path.parent() { + std::fs::create_dir_all(parent)?; + } + let original_width = 2304; + let original_height = 864; + let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255])); + image.save(&abs_path)?; + + let call_id = "view-image-null-detail"; + let arguments = serde_json::json!({ "path": rel_path, "detail": null }).to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "view_image", &arguments), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please attach the image with a null detail".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + service_tier: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let req = mock.single_request(); + let function_output = req.function_call_output(call_id); + let output_items = function_output + .get("output") + .and_then(Value::as_array) + .expect("function_call_output should be a content item array"); + assert_eq!(output_items.len(), 1); + assert_eq!(output_items[0].get("detail"), None); + let image_url = output_items[0] + .get("image_url") + .and_then(Value::as_str) + .expect("image_url present"); + + let (_, encoded) = image_url + .split_once(',') + .expect("image url contains data prefix"); + let decoded = BASE64_STANDARD + .decode(encoded) + .expect("image data decodes from base64 for request"); + let resized = load_from_memory(&decoded).expect("load resized image"); + let (width, height) = resized.dimensions(); + assert!(width <= 2048); + assert!(height <= 768); + assert!(width < original_width); + assert!(height < original_height); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -503,6 +688,110 @@ async fn view_image_tool_keeps_legacy_behavior_below_gpt5_3_codex() -> anyhow::R Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn view_image_tool_does_not_force_original_resolution_with_capability_feature_only() +-> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_model("gpt-5.3-codex") + .with_config(|config| { + config + .features + .enable(Feature::ImageDetailOriginal) + .expect("test config should allow feature update"); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let rel_path = "assets/original-example-capability-only.png"; + let abs_path = cwd.path().join(rel_path); + if let Some(parent) = abs_path.parent() { + std::fs::create_dir_all(parent)?; + } + let original_width = 2304; + let original_height = 864; + let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255])); + image.save(&abs_path)?; + + let call_id = "view-image-capability-only"; + let arguments = serde_json::json!({ "path": rel_path }).to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "view_image", &arguments), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please add the screenshot".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + service_tier: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event_with_timeout( + &codex, + |event| matches!(event, EventMsg::TurnComplete(_)), + Duration::from_secs(10), + ) + .await; + + let req = mock.single_request(); + let function_output = req.function_call_output(call_id); + let output_items = function_output + .get("output") + .and_then(Value::as_array) + .expect("function_call_output should be a content item array"); + assert_eq!(output_items.len(), 1); + assert_eq!(output_items[0].get("detail"), None); + let image_url = output_items[0] + .get("image_url") + .and_then(Value::as_str) + .expect("image_url present"); + + let (_, encoded) = image_url + .split_once(',') + .expect("image url contains data prefix"); + let decoded = BASE64_STANDARD + .decode(encoded) + .expect("image data decodes from base64 for request"); + let resized = load_from_memory(&decoded).expect("load resized image"); + let (resized_width, resized_height) = resized.dimensions(); + assert!(resized_width <= 2048); + assert!(resized_height <= 768); + assert!(resized_width < original_width); + assert!(resized_height < original_height); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn js_repl_emit_image_attaches_local_image() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index e31da9dc67..082a99905f 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -579,6 +579,8 @@ fn collab_spawn_begin_and_end_emit_item_events() { new_agent_nickname: None, new_agent_role: None, prompt: prompt.clone(), + model: "gpt-5".to_string(), + reasoning_effort: ReasoningEffortConfig::default(), status: AgentStatus::Running, }), ); diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 32d8d99f01..5b02be8bdc 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -12,29 +12,28 @@ into this binary. **Current Behavior** - Legacy Landlock + mount protections remain available as the legacy pipeline. -- The bubblewrap pipeline is standardized on the vendored path. -- During rollout, the bubblewrap pipeline is gated by the temporary feature - flag `use_linux_sandbox_bwrap` (CLI `-c` alias for - `features.use_linux_sandbox_bwrap`; legacy remains default when off). -- When enabled, the bubblewrap pipeline applies `PR_SET_NO_NEW_PRIVS` and a +- The default Linux sandbox pipeline is bubblewrap on the vendored path. +- Set `features.use_legacy_landlock = true` (or CLI `-c use_legacy_landlock=true`) + to force the legacy Landlock fallback. +- When the default bubblewrap pipeline is active, it applies `PR_SET_NO_NEW_PRIVS` and a seccomp network filter in-process. -- When enabled, the filesystem is read-only by default via `--ro-bind / /`. -- When enabled, writable roots are layered with `--bind `. -- When enabled, protected subpaths under writable roots (for example `.git`, +- When the default bubblewrap pipeline is active, the filesystem is read-only by default via `--ro-bind / /`. +- When the default bubblewrap pipeline is active, writable roots are layered with `--bind `. +- When the default bubblewrap pipeline is active, protected subpaths under writable roots (for example `.git`, resolved `gitdir:`, and `.codex`) are re-applied as read-only via `--ro-bind`. -- When enabled, symlink-in-path and non-existent protected paths inside +- When the default bubblewrap pipeline is active, symlink-in-path and non-existent protected paths inside writable roots are blocked by mounting `/dev/null` on the symlink or first missing component. -- When enabled, the helper explicitly isolates the user namespace via +- When the default bubblewrap pipeline is active, the helper explicitly isolates the user namespace via `--unshare-user` and the PID namespace via `--unshare-pid`. -- When enabled and network is restricted without proxy routing, the helper also +- When the default bubblewrap pipeline is active and network is restricted without proxy routing, the helper also isolates the network namespace via `--unshare-net`. - In managed proxy mode, the helper uses `--unshare-net` plus an internal TCP->UDS->TCP routing bridge so tool traffic reaches only configured proxy endpoints. - In managed proxy mode, after the bridge is live, seccomp blocks new AF_UNIX/socketpair creation for the user command. -- When enabled, it mounts a fresh `/proc` via `--proc /proc` by default, but +- When the default bubblewrap pipeline is active, it mounts a fresh `/proc` via `--proc /proc` by default, but you can skip this in restrictive container environments with `--no-proc`. **Notes** diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index e6304d2d60..a86e0128e3 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -22,7 +22,8 @@ use codex_protocol::protocol::SandboxPolicy; /// CLI surface for the Linux sandbox helper. /// /// The type name remains `LandlockCommand` for compatibility with existing -/// wiring, but the filesystem sandbox now uses bubblewrap. +/// wiring, but bubblewrap is now the default filesystem sandbox and Landlock +/// is the legacy fallback. pub struct LandlockCommand { /// It is possible that the cwd used in the context of the sandbox policy /// is different from the cwd of the process to spawn. @@ -42,11 +43,11 @@ pub struct LandlockCommand { #[arg(long = "network-sandbox-policy", hide = true)] pub network_sandbox_policy: Option, - /// Opt-in: use the bubblewrap-based Linux sandbox pipeline. + /// Opt-in: use the legacy Landlock Linux sandbox fallback. /// - /// When not set, we fall back to the legacy Landlock + mount pipeline. - #[arg(long = "use-bwrap-sandbox", hide = true, default_value_t = false)] - pub use_bwrap_sandbox: bool, + /// When not set, the helper uses the default bubblewrap pipeline. + #[arg(long = "use-legacy-landlock", hide = true, default_value_t = false)] + pub use_legacy_landlock: bool, /// Internal: apply seccomp and `no_new_privs` in the already-sandboxed /// process, then exec the user command. @@ -92,7 +93,7 @@ pub fn run_main() -> ! { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, - use_bwrap_sandbox, + use_legacy_landlock, apply_seccomp_then_exec, allow_network_for_proxy, proxy_route_spec, @@ -103,7 +104,7 @@ pub fn run_main() -> ! { if command.is_empty() { panic!("No command specified to execute."); } - ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_bwrap_sandbox); + ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_legacy_landlock); let EffectiveSandboxPolicies { sandbox_policy, file_system_sandbox_policy, @@ -154,7 +155,7 @@ pub fn run_main() -> ! { exec_or_panic(command); } - if use_bwrap_sandbox { + if !use_legacy_landlock { // Outer stage: bubblewrap first, then re-enter this binary in the // sandboxed environment to apply seccomp. This path never falls back // to legacy Landlock on failure. @@ -171,7 +172,6 @@ pub fn run_main() -> ! { sandbox_policy: &sandbox_policy, file_system_sandbox_policy: &file_system_sandbox_policy, network_sandbox_policy, - use_bwrap_sandbox, allow_network_for_proxy, proxy_route_spec, command, @@ -256,9 +256,9 @@ fn resolve_sandbox_policies( } } -fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_sandbox: bool) { - if apply_seccomp_then_exec && !use_bwrap_sandbox { - panic!("--apply-seccomp-then-exec requires --use-bwrap-sandbox"); +fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_legacy_landlock: bool) { + if apply_seccomp_then_exec && use_legacy_landlock { + panic!("--apply-seccomp-then-exec is incompatible with --use-legacy-landlock"); } } @@ -280,7 +280,8 @@ fn run_bwrap_with_proc_fallback( network_mode, ) { - eprintln!("codex-linux-sandbox: bwrap could not mount /proc; retrying with --no-proc"); + // Keep the retry silent so sandbox-internal diagnostics do not leak into the + // child process stderr stream. mount_proc = false; } @@ -470,7 +471,6 @@ struct InnerSeccompCommandArgs<'a> { sandbox_policy: &'a SandboxPolicy, file_system_sandbox_policy: &'a FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, - use_bwrap_sandbox: bool, allow_network_for_proxy: bool, proxy_route_spec: Option, command: Vec, @@ -483,7 +483,6 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, - use_bwrap_sandbox, allow_network_for_proxy, proxy_route_spec, command, @@ -515,11 +514,8 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec file_system_policy_json, "--network-sandbox-policy".to_string(), network_policy_json, + "--apply-seccomp-then-exec".to_string(), ]; - if use_bwrap_sandbox { - inner.push("--use-bwrap-sandbox".to_string()); - inner.push("--apply-seccomp-then-exec".to_string()); - } if allow_network_for_proxy { inner.push("--allow-network-for-proxy".to_string()); let proxy_route_spec = proxy_route_spec diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index a1adf65b1d..dec1c12144 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -127,7 +127,6 @@ fn managed_proxy_inner_command_includes_route_spec() { sandbox_policy: &sandbox_policy, file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::Restricted, - use_bwrap_sandbox: true, allow_network_for_proxy: true, proxy_route_spec: Some("{\"routes\":[]}".to_string()), command: vec!["/bin/true".to_string()], @@ -145,7 +144,6 @@ fn inner_command_includes_split_policy_flags() { sandbox_policy: &sandbox_policy, file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::Restricted, - use_bwrap_sandbox: true, allow_network_for_proxy: false, proxy_route_spec: None, command: vec!["/bin/true".to_string()], @@ -163,7 +161,6 @@ fn non_managed_inner_command_omits_route_spec() { sandbox_policy: &sandbox_policy, file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::Restricted, - use_bwrap_sandbox: true, allow_network_for_proxy: false, proxy_route_spec: None, command: vec!["/bin/true".to_string()], @@ -181,7 +178,6 @@ fn managed_proxy_inner_command_requires_route_spec() { sandbox_policy: &sandbox_policy, file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::Restricted, - use_bwrap_sandbox: true, allow_network_for_proxy: true, proxy_route_spec: None, command: vec!["/bin/true".to_string()], @@ -244,8 +240,8 @@ fn resolve_sandbox_policies_rejects_partial_split_policies() { } #[test] -fn apply_seccomp_then_exec_without_bwrap_panics() { - let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, false)); +fn apply_seccomp_then_exec_with_legacy_landlock_panics() { + let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, true)); assert!(result.is_err()); } @@ -253,5 +249,5 @@ fn apply_seccomp_then_exec_without_bwrap_panics() { fn valid_inner_stage_modes_do_not_panic() { ensure_inner_stage_mode_is_valid(false, false); ensure_inner_stage_mode_is_valid(false, true); - ensure_inner_stage_mode_is_valid(true, true); + ensure_inner_stage_mode_is_valid(true, false); } diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 8e2f5ef41e..774fb4f17b 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -72,7 +72,7 @@ async fn run_cmd_result_with_writable_roots( cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64, - use_bwrap_sandbox: bool, + use_legacy_landlock: bool, network_access: bool, ) -> Result { let sandbox_policy = SandboxPolicy::WorkspaceWrite { @@ -96,7 +96,7 @@ async fn run_cmd_result_with_writable_roots( file_system_sandbox_policy, network_sandbox_policy, timeout_ms, - use_bwrap_sandbox, + use_legacy_landlock, ) .await } @@ -108,7 +108,7 @@ async fn run_cmd_result_with_policies( file_system_sandbox_policy: FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, timeout_ms: u64, - use_bwrap_sandbox: bool, + use_legacy_landlock: bool, ) -> Result { let cwd = std::env::current_dir().expect("cwd should exist"); let sandbox_cwd = cwd.clone(); @@ -133,7 +133,7 @@ async fn run_cmd_result_with_policies( network_sandbox_policy, sandbox_cwd.as_path(), &codex_linux_sandbox_exe, - use_bwrap_sandbox, + use_legacy_landlock, None, ) .await @@ -155,7 +155,7 @@ async fn should_skip_bwrap_tests() -> bool { &["bash", "-lc", "true"], &[], NETWORK_TIMEOUT_MS, - true, + false, true, ) .await @@ -216,7 +216,7 @@ async fn test_dev_null_write() { // We have seen timeouts when running this test in CI on GitHub, // so we are using a generous timeout until we can diagnose further. LONG_TIMEOUT_MS, - true, + false, true, ) .await @@ -240,7 +240,7 @@ async fn bwrap_populates_minimal_dev_nodes() { ], &[], LONG_TIMEOUT_MS, - true, + false, true, ) .await @@ -278,7 +278,7 @@ async fn bwrap_preserves_writable_dev_shm_bind_mount() { ], &[PathBuf::from("/dev/shm")], LONG_TIMEOUT_MS, - true, + false, true, ) .await @@ -442,7 +442,7 @@ async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() { ], &[tmpdir.path().to_path_buf()], LONG_TIMEOUT_MS, - true, + false, true, ) .await, @@ -458,7 +458,7 @@ async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() { ], &[tmpdir.path().to_path_buf()], LONG_TIMEOUT_MS, - true, + false, true, ) .await, @@ -495,7 +495,7 @@ async fn sandbox_blocks_codex_symlink_replacement_attack() { ], &[tmpdir.path().to_path_buf()], LONG_TIMEOUT_MS, - true, + false, true, ) .await, @@ -548,7 +548,7 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() { file_system_sandbox_policy, NetworkSandboxPolicy::Enabled, LONG_TIMEOUT_MS, - true, + false, ) .await, "explicit split-policy carveout should be denied under bubblewrap", @@ -599,7 +599,7 @@ async fn sandbox_blocks_root_read_carveouts_under_bwrap() { file_system_sandbox_policy, NetworkSandboxPolicy::Enabled, LONG_TIMEOUT_MS, - true, + false, ) .await, "root-read carveout should be denied under bubblewrap", diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index e27930cdb5..4d7aa2ac7c 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -133,7 +133,6 @@ async fn run_linux_sandbox_direct( cwd.to_string_lossy().to_string(), "--sandbox-policy".to_string(), policy_json, - "--use-bwrap-sandbox".to_string(), ]; if allow_network_for_proxy { args.push("--allow-network-for-proxy".to_string()); diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index 4f88d25383..879c9e91a2 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -30,6 +30,7 @@ use crate::upstream::UpstreamClient; use crate::upstream::proxy_for_connect; use anyhow::Context as _; use anyhow::Result; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use rama_core::Layer; use rama_core::Service; use rama_core::error::BoxError; @@ -38,7 +39,6 @@ use rama_core::error::OpaqueError; use rama_core::extensions::ExtensionsMut; use rama_core::extensions::ExtensionsRef; use rama_core::layer::AddInputExtensionLayer; -use rama_core::rt::Executor; use rama_core::service::service_fn; use rama_http::Body; use rama_http::HeaderMap; @@ -113,11 +113,17 @@ async fn run_http_proxy_with_listener( listener: TcpListener, policy_decider: Option>, ) -> Result<()> { + ensure_rustls_crypto_provider(); + let addr = listener .local_addr() .context("read HTTP proxy listener local addr")?; - let http_service = HttpServer::auto(Executor::new()).service( + // This proxy listener only needs HTTP/1 proxy semantics. Using Rama's auto builder + // forces every accepted socket through the HTTP version sniffing pre-read path before proxy + // request parsing, which can stall some local clients on macOS before CONNECT/absolute-form + // handling runs at all. + let http_service = HttpServer::http1().service( ( UpgradeLayer::new( MethodMatcher::CONNECT, @@ -977,7 +983,14 @@ mod tests { use pretty_assertions::assert_eq; use rama_http::Method; use rama_http::Request; + use std::net::Ipv4Addr; + use std::net::TcpListener as StdTcpListener; use std::sync::Arc; + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener as TokioTcpListener; + use tokio::time::Duration; + use tokio::time::timeout; #[tokio::test] async fn http_connect_accept_blocks_in_limited_mode() { @@ -1024,6 +1037,65 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test] + async fn http_proxy_listener_accepts_plain_http1_connect_requests() { + let target_listener = TokioTcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("target listener should bind"); + let target_addr = target_listener + .local_addr() + .expect("target listener should expose local addr"); + let target_task = tokio::spawn(async move { + let (mut stream, _) = target_listener + .accept() + .await + .expect("target listener should accept"); + let mut buf = [0_u8; 1]; + let _ = timeout(Duration::from_secs(1), stream.read(&mut buf)).await; + }); + + let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["127.0.0.1".to_string()], + allow_local_binding: true, + ..NetworkProxySettings::default() + })); + let listener = + StdTcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("proxy listener should bind"); + let proxy_addr = listener + .local_addr() + .expect("proxy listener should expose local addr"); + let proxy_task = tokio::spawn(run_http_proxy_with_std_listener(state, listener, None)); + + let mut stream = tokio::net::TcpStream::connect(proxy_addr) + .await + .expect("client should connect to proxy"); + let request = format!( + "CONNECT 127.0.0.1:{port} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n", + port = target_addr.port() + ); + stream + .write_all(request.as_bytes()) + .await + .expect("client should write CONNECT request"); + + let mut buf = [0_u8; 256]; + let bytes_read = timeout(Duration::from_secs(2), stream.read(&mut buf)) + .await + .expect("proxy should respond before timeout") + .expect("client should read proxy response"); + let response = String::from_utf8_lossy(&buf[..bytes_read]); + assert!( + response.starts_with("HTTP/1.1 200 OK\r\n"), + "unexpected proxy response: {response:?}" + ); + + drop(stream); + proxy_task.abort(); + let _ = proxy_task.await; + target_task.abort(); + let _ = target_task.await; + } + #[tokio::test(flavor = "current_thread")] async fn http_plain_proxy_blocks_unix_socket_when_method_not_allowed() { let state = Arc::new(network_proxy_state_for_policy( diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index fbb42dfce7..8f596f6842 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -8,7 +8,6 @@ use crate::state::NetworkProxyState; use anyhow::Context; use anyhow::Result; use clap::Parser; -use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use std::collections::HashMap; use std::net::SocketAddr; use std::net::TcpListener as StdTcpListener; @@ -433,8 +432,6 @@ impl NetworkProxy { return Ok(NetworkProxyHandle::noop()); } - ensure_rustls_crypto_provider(); - if !unix_socket_permissions_supported() { warn!( "allowUnixSockets and dangerouslyAllowAllUnixSockets are macOS-only; requests will be rejected on this platform" diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 9bb0b82fd3..327416093a 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -898,7 +898,9 @@ impl SessionTelemetry { ResponseItem::Reasoning { .. } => "reasoning".into(), ResponseItem::LocalShellCall { .. } => "local_shell_call".into(), ResponseItem::FunctionCall { .. } => "function_call".into(), + ResponseItem::ToolSearchCall { .. } => "tool_search_call".into(), ResponseItem::FunctionCallOutput { .. } => "function_call_output".into(), + ResponseItem::ToolSearchOutput { .. } => "tool_search_output".into(), ResponseItem::CustomToolCall { .. } => "custom_tool_call".into(), ResponseItem::CustomToolCallOutput { .. } => "custom_tool_call_output".into(), ResponseItem::WebSearchCall { .. } => "web_search_call".into(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 031a611f26..b0e6f36386 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -241,6 +241,13 @@ pub enum ResponseInputItem { call_id: String, output: FunctionCallOutputPayload, }, + ToolSearchOutput { + call_id: String, + status: String, + execution: String, + #[ts(type = "unknown[]")] + tools: Vec, + }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -331,12 +338,27 @@ pub enum ResponseItem { #[ts(skip)] id: Option, name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + namespace: Option, // The Responses API returns the function call arguments as a *string* that contains // JSON, not as an already‑parsed object. We keep it as a raw string here and let // Session::handle_function_call parse it into a Value. arguments: String, call_id: String, }, + ToolSearchCall { + #[serde(default, skip_serializing)] + #[ts(skip)] + id: Option, + call_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + status: Option, + execution: String, + #[ts(type = "unknown")] + arguments: serde_json::Value, + }, // NOTE: The `output` field for `function_call_output` uses a dedicated payload type with // custom serialization. On the wire it is either: // - a plain string (`content`) @@ -365,6 +387,13 @@ pub enum ResponseItem { call_id: String, output: FunctionCallOutputPayload, }, + ToolSearchOutput { + call_id: Option, + status: String, + execution: String, + #[ts(type = "unknown[]")] + tools: Vec, + }, // Emitted by the Responses API when the agent triggers a web search. // Example payload (from SSE `response.output_item.done`): // { @@ -896,6 +925,17 @@ impl From for ResponseItem { ResponseInputItem::CustomToolCallOutput { call_id, output } => { Self::CustomToolCallOutput { call_id, output } } + ResponseInputItem::ToolSearchOutput { + call_id, + status, + execution, + tools, + } => Self::ToolSearchOutput { + call_id: Some(call_id), + status, + execution, + tools, + }, } } } @@ -1001,6 +1041,13 @@ impl From> for ResponseInputItem { } } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +pub struct SearchToolCallParams { + pub query: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub limit: Option, +} /// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec` /// or `shell`, the `arguments` field should deserialize to this struct. @@ -1734,6 +1781,29 @@ mod tests { assert_eq!(text, Some("line 1".to_string())); } + #[test] + fn function_call_deserializes_optional_namespace() { + let item: ResponseItem = serde_json::from_value(serde_json::json!({ + "type": "function_call", + "name": "mcp__codex_apps__gmail_get_recent_emails", + "namespace": "mcp__codex_apps__gmail", + "arguments": "{\"top_k\":5}", + "call_id": "call-1", + })) + .expect("function_call should deserialize"); + + assert_eq!( + item, + ResponseItem::FunctionCall { + id: None, + name: "mcp__codex_apps__gmail_get_recent_emails".to_string(), + namespace: Some("mcp__codex_apps__gmail".to_string()), + arguments: "{\"top_k\":5}".to_string(), + call_id: "call-1".to_string(), + } + ); + } + #[test] fn converts_sandbox_mode_into_developer_instructions() { let workspace_write: DeveloperInstructions = SandboxMode::WorkspaceWrite.into(); @@ -2206,6 +2276,169 @@ mod tests { Ok(()) } + #[test] + fn tool_search_call_roundtrips() -> Result<()> { + let parsed: ResponseItem = serde_json::from_str( + r#"{ + "type": "tool_search_call", + "call_id": "search-1", + "execution": "client", + "arguments": { + "query": "calendar create", + "limit": 1 + } + }"#, + )?; + + assert_eq!( + parsed, + ResponseItem::ToolSearchCall { + id: None, + call_id: Some("search-1".to_string()), + status: None, + execution: "client".to_string(), + arguments: serde_json::json!({ + "query": "calendar create", + "limit": 1, + }), + } + ); + + assert_eq!( + serde_json::to_value(&parsed)?, + serde_json::json!({ + "type": "tool_search_call", + "call_id": "search-1", + "execution": "client", + "arguments": { + "query": "calendar create", + "limit": 1, + } + }) + ); + + Ok(()) + } + + #[test] + fn tool_search_output_roundtrips() -> Result<()> { + let input = ResponseInputItem::ToolSearchOutput { + call_id: "search-1".to_string(), + status: "completed".to_string(), + execution: "client".to_string(), + tools: vec![serde_json::json!({ + "type": "function", + "name": "mcp__codex_apps__calendar_create_event", + "description": "Create a calendar event.", + "defer_loading": true, + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string"} + }, + "required": ["title"], + "additionalProperties": false, + } + })], + }; + assert_eq!( + ResponseItem::from(input.clone()), + ResponseItem::ToolSearchOutput { + call_id: Some("search-1".to_string()), + status: "completed".to_string(), + execution: "client".to_string(), + tools: vec![serde_json::json!({ + "type": "function", + "name": "mcp__codex_apps__calendar_create_event", + "description": "Create a calendar event.", + "defer_loading": true, + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string"} + }, + "required": ["title"], + "additionalProperties": false, + } + })], + } + ); + + assert_eq!( + serde_json::to_value(input)?, + serde_json::json!({ + "type": "tool_search_output", + "call_id": "search-1", + "status": "completed", + "execution": "client", + "tools": [{ + "type": "function", + "name": "mcp__codex_apps__calendar_create_event", + "description": "Create a calendar event.", + "defer_loading": true, + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string"} + }, + "required": ["title"], + "additionalProperties": false, + } + }] + }) + ); + + Ok(()) + } + + #[test] + fn tool_search_server_items_allow_null_call_id() -> Result<()> { + let parsed_call: ResponseItem = serde_json::from_str( + r#"{ + "type": "tool_search_call", + "execution": "server", + "call_id": null, + "status": "completed", + "arguments": { + "paths": ["crm"] + } + }"#, + )?; + assert_eq!( + parsed_call, + ResponseItem::ToolSearchCall { + id: None, + call_id: None, + status: Some("completed".to_string()), + execution: "server".to_string(), + arguments: serde_json::json!({ + "paths": ["crm"], + }), + } + ); + + let parsed_output: ResponseItem = serde_json::from_str( + r#"{ + "type": "tool_search_output", + "execution": "server", + "call_id": null, + "status": "completed", + "tools": [] + }"#, + )?; + assert_eq!( + parsed_output, + ResponseItem::ToolSearchOutput { + call_id: None, + status: "completed".to_string(), + execution: "server".to_string(), + tools: vec![], + } + ); + + Ok(()) + } + #[test] fn mixed_remote_and_local_images_share_label_sequence() -> Result<()> { let image_url = "data:image/png;base64,abc".to_string(); diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index 9624f36cd6..6c2e7d336f 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -34,13 +34,31 @@ impl NetworkSandboxPolicy { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)] +/// Access mode for a filesystem entry. +/// +/// When 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`. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize, + Display, + JsonSchema, + TS, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum FileSystemAccessMode { - None, Read, Write, + None, } impl FileSystemAccessMode { @@ -121,6 +139,22 @@ pub struct FileSystemSandboxPolicy { pub entries: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResolvedFileSystemEntry { + path: AbsolutePathBuf, + access: FileSystemAccessMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FileSystemSemanticSignature { + has_full_disk_read_access: bool, + has_full_disk_write_access: bool, + include_platform_defaults: bool, + readable_roots: Vec, + writable_roots: Vec, + unreadable_roots: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type")] @@ -163,6 +197,43 @@ impl FileSystemSandboxPolicy { .any(|entry| entry.access == FileSystemAccessMode::None) } + /// Returns true when a restricted policy contains any entry that really + /// reduces a broader `:root = write` grant. + /// + /// Raw entry presence is not enough here: an equally specific `write` + /// entry for the same target wins under the normal precedence rules, so a + /// shadowed `read` entry must not downgrade the policy out of full-disk + /// write mode. + fn has_write_narrowing_entries(&self) -> bool { + matches!(self.kind, FileSystemSandboxKind::Restricted) + && self.entries.iter().any(|entry| { + if entry.access.can_write() { + return false; + } + + match &entry.path { + FileSystemPath::Path { .. } => !self.has_same_target_write_override(entry), + FileSystemPath::Special { value } => match value { + FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::None, + FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => { + false + } + _ => !self.has_same_target_write_override(entry), + }, + } + }) + } + + /// Returns true when a higher-priority `write` entry targets the same + /// location as `entry`, so `entry` cannot narrow effective write access. + fn has_same_target_write_override(&self, entry: &FileSystemSandboxEntry) -> bool { + self.entries.iter().any(|candidate| { + candidate.access.can_write() + && candidate.access > entry.access + && file_system_paths_share_target(&candidate.path, &entry.path) + }) + } + pub fn unrestricted() -> Self { Self { kind: FileSystemSandboxKind::Unrestricted, @@ -229,7 +300,7 @@ impl FileSystemSandboxPolicy { FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true, FileSystemSandboxKind::Restricted => { self.has_root_access(FileSystemAccessMode::can_write) - && !self.has_explicit_deny_entries() + && !self.has_write_narrowing_entries() } } } @@ -248,31 +319,64 @@ impl FileSystemSandboxPolicy { }) } + pub fn resolve_access_with_cwd(&self, path: &Path, cwd: &Path) -> FileSystemAccessMode { + match self.kind { + FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => { + return FileSystemAccessMode::Write; + } + FileSystemSandboxKind::Restricted => {} + } + + let Some(path) = resolve_candidate_path(path, cwd) else { + return FileSystemAccessMode::None; + }; + + self.resolved_entries_with_cwd(cwd) + .into_iter() + .filter(|entry| path.as_path().starts_with(entry.path.as_path())) + .max_by_key(resolved_entry_precedence) + .map(|entry| entry.access) + .unwrap_or(FileSystemAccessMode::None) + } + + pub fn can_read_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool { + self.resolve_access_with_cwd(path, cwd).can_read() + } + + pub fn can_write_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool { + self.resolve_access_with_cwd(path, cwd).can_write() + } + + pub fn needs_direct_runtime_enforcement( + &self, + network_policy: NetworkSandboxPolicy, + cwd: &Path, + ) -> bool { + if !matches!(self.kind, FileSystemSandboxKind::Restricted) { + return false; + } + + let Ok(legacy_policy) = self.to_legacy_sandbox_policy(network_policy, cwd) else { + return true; + }; + + self.semantic_signature(cwd) + != FileSystemSandboxPolicy::from_legacy_sandbox_policy(&legacy_policy, cwd) + .semantic_signature(cwd) + } + /// Returns the explicit readable roots resolved against the provided cwd. pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec { if self.has_full_disk_read_access() { return Vec::new(); } - let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); - let mut readable_roots = Vec::new(); - if self.has_root_access(FileSystemAccessMode::can_read) - && let Some(cwd_absolute) = cwd_absolute.as_ref() - { - readable_roots.push(absolute_root_path_for_cwd(cwd_absolute)); - } - dedup_absolute_paths( - readable_roots + self.resolved_entries_with_cwd(cwd) .into_iter() - .chain( - self.entries - .iter() - .filter(|entry| entry.access.can_read()) - .filter_map(|entry| { - resolve_file_system_path(&entry.path, cwd_absolute.as_ref()) - }), - ) + .filter(|entry| entry.access.can_read()) + .filter(|entry| self.can_read_path_with_cwd(entry.path.as_path(), cwd)) + .map(|entry| entry.path) .collect(), ) } @@ -284,32 +388,22 @@ impl FileSystemSandboxPolicy { return Vec::new(); } - let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); + let resolved_entries = self.resolved_entries_with_cwd(cwd); let read_only_roots = dedup_absolute_paths( - self.entries + resolved_entries .iter() .filter(|entry| !entry.access.can_write()) - .filter_map(|entry| resolve_file_system_path(&entry.path, cwd_absolute.as_ref())) + .filter(|entry| !self.can_write_path_with_cwd(entry.path.as_path(), cwd)) + .map(|entry| entry.path.clone()) .collect(), ); - let mut writable_roots = Vec::new(); - if self.has_root_access(FileSystemAccessMode::can_write) - && let Some(cwd_absolute) = cwd_absolute.as_ref() - { - writable_roots.push(absolute_root_path_for_cwd(cwd_absolute)); - } dedup_absolute_paths( - writable_roots + resolved_entries .into_iter() - .chain( - self.entries - .iter() - .filter(|entry| entry.access.can_write()) - .filter_map(|entry| { - resolve_file_system_path(&entry.path, cwd_absolute.as_ref()) - }), - ) + .filter(|entry| entry.access.can_write()) + .filter(|entry| self.can_write_path_with_cwd(entry.path.as_path(), cwd)) + .map(|entry| entry.path) .collect(), ) .into_iter() @@ -339,12 +433,20 @@ impl FileSystemSandboxPolicy { return Vec::new(); } - let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); + let root = AbsolutePathBuf::from_absolute_path(cwd) + .ok() + .map(|cwd| absolute_root_path_for_cwd(&cwd)); + dedup_absolute_paths( - self.entries + self.resolved_entries_with_cwd(cwd) .iter() .filter(|entry| entry.access == FileSystemAccessMode::None) - .filter_map(|entry| resolve_file_system_path(&entry.path, cwd_absolute.as_ref())) + .filter(|entry| !self.can_read_path_with_cwd(entry.path.as_path(), cwd)) + // Restricted policies already deny reads outside explicit allow roots, + // so materializing the filesystem root here would erase narrower + // readable carveouts when downstream sandboxes apply deny masks last. + .filter(|entry| root.as_ref() != Some(&entry.path)) + .map(|entry| entry.path.clone()) .collect(), ) } @@ -504,6 +606,32 @@ impl FileSystemSandboxPolicy { } }) } + + fn resolved_entries_with_cwd(&self, cwd: &Path) -> Vec { + let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); + self.entries + .iter() + .filter_map(|entry| { + resolve_entry_path(&entry.path, cwd_absolute.as_ref()).map(|path| { + ResolvedFileSystemEntry { + path, + access: entry.access, + } + }) + }) + .collect() + } + + fn semantic_signature(&self, cwd: &Path) -> FileSystemSemanticSignature { + FileSystemSemanticSignature { + has_full_disk_read_access: self.has_full_disk_read_access(), + has_full_disk_write_access: self.has_full_disk_write_access(), + include_platform_defaults: self.include_platform_defaults(), + readable_roots: self.get_readable_roots_with_cwd(cwd), + writable_roots: self.get_writable_roots_with_cwd(cwd), + unreadable_roots: self.get_unreadable_roots_with_cwd(cwd), + } + } } impl From<&SandboxPolicy> for NetworkSandboxPolicy { @@ -641,6 +769,108 @@ fn resolve_file_system_path( } } +fn resolve_entry_path( + path: &FileSystemPath, + cwd: Option<&AbsolutePathBuf>, +) -> Option { + match path { + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + } => cwd.map(absolute_root_path_for_cwd), + _ => resolve_file_system_path(path, cwd), + } +} + +fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option { + if path.is_absolute() { + AbsolutePathBuf::from_absolute_path(path).ok() + } else { + AbsolutePathBuf::resolve_path_against_base(path, cwd).ok() + } +} + +/// Returns true when two config paths refer to the same exact target before +/// any prefix matching is applied. +/// +/// This is intentionally narrower than full path resolution: it only answers +/// the "can one entry shadow another at the same specificity?" question used +/// by `has_write_narrowing_entries`. +fn file_system_paths_share_target(left: &FileSystemPath, right: &FileSystemPath) -> bool { + match (left, right) { + (FileSystemPath::Path { path: left }, FileSystemPath::Path { path: right }) => { + left == right + } + (FileSystemPath::Special { value: left }, FileSystemPath::Special { value: right }) => { + special_paths_share_target(left, right) + } + (FileSystemPath::Path { path }, FileSystemPath::Special { value }) + | (FileSystemPath::Special { value }, FileSystemPath::Path { path }) => { + special_path_matches_absolute_path(value, path) + } + } +} + +/// Compares special-path tokens that resolve to the same concrete target +/// without needing a cwd. +fn special_paths_share_target(left: &FileSystemSpecialPath, right: &FileSystemSpecialPath) -> bool { + match (left, right) { + (FileSystemSpecialPath::Root, FileSystemSpecialPath::Root) + | (FileSystemSpecialPath::Minimal, FileSystemSpecialPath::Minimal) + | ( + FileSystemSpecialPath::CurrentWorkingDirectory, + FileSystemSpecialPath::CurrentWorkingDirectory, + ) + | (FileSystemSpecialPath::Tmpdir, FileSystemSpecialPath::Tmpdir) + | (FileSystemSpecialPath::SlashTmp, FileSystemSpecialPath::SlashTmp) => true, + ( + FileSystemSpecialPath::CurrentWorkingDirectory, + FileSystemSpecialPath::ProjectRoots { subpath: None }, + ) + | ( + FileSystemSpecialPath::ProjectRoots { subpath: None }, + FileSystemSpecialPath::CurrentWorkingDirectory, + ) => true, + ( + FileSystemSpecialPath::ProjectRoots { subpath: left }, + FileSystemSpecialPath::ProjectRoots { subpath: right }, + ) => left == right, + ( + FileSystemSpecialPath::Unknown { + path: left, + subpath: left_subpath, + }, + FileSystemSpecialPath::Unknown { + path: right, + subpath: right_subpath, + }, + ) => left == right && left_subpath == right_subpath, + _ => false, + } +} + +/// Matches cwd-independent special paths against absolute `Path` entries when +/// they name the same location. +/// +/// We intentionally only fold the special paths whose concrete meaning is +/// stable without a cwd, such as `/` and `/tmp`. +fn special_path_matches_absolute_path( + value: &FileSystemSpecialPath, + path: &AbsolutePathBuf, +) -> bool { + match value { + FileSystemSpecialPath::Root => path.as_path().parent().is_none(), + FileSystemSpecialPath::SlashTmp => path.as_path() == Path::new("/tmp"), + _ => false, + } +} + +/// Orders resolved entries so the most specific path wins first, then applies +/// the access tie-breaker from [`FileSystemAccessMode`]. +fn resolved_entry_precedence(entry: &ResolvedFileSystemEntry) -> (usize, FileSystemAccessMode) { + let specificity = entry.path.as_path().components().count(); + (specificity, entry.access) +} + fn absolute_root_path_for_cwd(cwd: &AbsolutePathBuf) -> AbsolutePathBuf { let root = cwd .as_path() @@ -808,6 +1038,7 @@ fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option std::io::Result<()> { @@ -835,4 +1066,204 @@ mod tests { ); Ok(()) } + + #[test] + fn resolve_access_with_cwd_uses_most_specific_entry() { + let cwd = TempDir::new().expect("tempdir"); + let docs = + AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs"); + let docs_private = AbsolutePathBuf::resolve_path_against_base("docs/private", cwd.path()) + .expect("resolve docs/private"); + let docs_private_public = + AbsolutePathBuf::resolve_path_against_base("docs/private/public", cwd.path()) + .expect("resolve docs/private/public"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: docs.clone() }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: docs_private.clone(), + }, + access: FileSystemAccessMode::None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: docs_private_public.clone(), + }, + access: FileSystemAccessMode::Write, + }, + ]); + + assert_eq!( + policy.resolve_access_with_cwd(cwd.path(), cwd.path()), + FileSystemAccessMode::Write + ); + assert_eq!( + policy.resolve_access_with_cwd(docs.as_path(), cwd.path()), + FileSystemAccessMode::Read + ); + assert_eq!( + policy.resolve_access_with_cwd(docs_private.as_path(), cwd.path()), + FileSystemAccessMode::None + ); + assert_eq!( + policy.resolve_access_with_cwd(docs_private_public.as_path(), cwd.path()), + FileSystemAccessMode::Write + ); + } + + #[test] + fn split_only_nested_carveouts_need_direct_runtime_enforcement() { + let cwd = TempDir::new().expect("tempdir"); + let docs = + AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: docs }, + access: FileSystemAccessMode::Read, + }, + ]); + + assert!( + policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),) + ); + + let legacy_workspace_write = + FileSystemSandboxPolicy::from(&SandboxPolicy::new_workspace_write_policy()); + assert!( + !legacy_workspace_write + .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),) + ); + } + + #[test] + fn root_write_with_read_only_child_is_not_full_disk_write() { + let cwd = TempDir::new().expect("tempdir"); + let docs = + AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: docs.clone() }, + access: FileSystemAccessMode::Read, + }, + ]); + + assert!(!policy.has_full_disk_write_access()); + assert_eq!( + policy.resolve_access_with_cwd(docs.as_path(), cwd.path()), + FileSystemAccessMode::Read + ); + assert!( + policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),) + ); + } + + #[test] + fn root_deny_does_not_materialize_as_unreadable_root() { + let cwd = TempDir::new().expect("tempdir"); + let docs = + AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: docs.clone() }, + access: FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + policy.resolve_access_with_cwd(docs.as_path(), cwd.path()), + FileSystemAccessMode::Read + ); + assert_eq!(policy.get_readable_roots_with_cwd(cwd.path()), vec![docs]); + assert!(policy.get_unreadable_roots_with_cwd(cwd.path()).is_empty()); + } + + #[test] + fn duplicate_root_deny_prevents_full_disk_write_access() { + let cwd = TempDir::new().expect("tempdir"); + let root = AbsolutePathBuf::from_absolute_path(cwd.path()) + .map(|cwd| absolute_root_path_for_cwd(&cwd)) + .expect("resolve filesystem root"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::None, + }, + ]); + + assert!(!policy.has_full_disk_write_access()); + assert_eq!( + policy.resolve_access_with_cwd(root.as_path(), cwd.path()), + FileSystemAccessMode::None + ); + } + + #[test] + fn same_specificity_write_override_keeps_full_disk_write_access() { + let cwd = TempDir::new().expect("tempdir"); + let docs = + AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: docs.clone() }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: docs.clone() }, + access: FileSystemAccessMode::Write, + }, + ]); + + assert!(policy.has_full_disk_write_access()); + assert_eq!( + policy.resolve_access_with_cwd(docs.as_path(), cwd.path()), + FileSystemAccessMode::Write + ); + } + + #[test] + fn file_system_access_mode_orders_by_conflict_precedence() { + assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read); + assert!(FileSystemAccessMode::None > FileSystemAccessMode::Write); + } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 8c4b8ca1ab..267bb7bf7c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3180,6 +3180,10 @@ pub struct CollabAgentSpawnEndEvent { /// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the /// beginning. pub prompt: String, + /// Model requested for the spawned agent. + pub model: String, + /// Reasoning effort requested for the spawned agent. + pub reasoning_effort: ReasoningEffortConfig, /// Last known status of the new agent reported to the sender agent. pub status: AgentStatus, } diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index c3bb6be0e6..f679c077b0 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -451,6 +451,7 @@ pub struct ToolWithConnectorId { pub tool: Tool, pub connector_id: Option, pub connector_name: Option, + pub connector_description: Option, } pub struct ListToolsWithConnectorIdResult { @@ -616,10 +617,13 @@ impl RmcpClient { let connector_id = Self::meta_string(meta, "connector_id"); let connector_name = Self::meta_string(meta, "connector_name") .or_else(|| Self::meta_string(meta, "connector_display_name")); + let connector_description = Self::meta_string(meta, "connector_description") + .or_else(|| Self::meta_string(meta, "connectorDescription")); Ok(ToolWithConnectorId { tool, connector_id, connector_name, + connector_description, }) }) .collect::>>()?; diff --git a/codex-rs/stdio-to-uds/Cargo.toml b/codex-rs/stdio-to-uds/Cargo.toml index 20e3ade720..bc62ee8d25 100644 --- a/codex-rs/stdio-to-uds/Cargo.toml +++ b/codex-rs/stdio-to-uds/Cargo.toml @@ -22,7 +22,6 @@ anyhow = { workspace = true } uds_windows = { workspace = true } [dev-dependencies] -assert_cmd = { workspace = true } codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/stdio-to-uds/src/lib.rs b/codex-rs/stdio-to-uds/src/lib.rs index 1190688844..9d2cb6c0f0 100644 --- a/codex-rs/stdio-to-uds/src/lib.rs +++ b/codex-rs/stdio-to-uds/src/lib.rs @@ -39,9 +39,13 @@ pub fn run(socket_path: &Path) -> anyhow::Result<()> { io::copy(&mut handle, &mut stream).context("failed to copy data from stdin to socket")?; } - stream - .shutdown(Shutdown::Write) - .context("failed to shutdown socket writer")?; + // The peer can close immediately after sending its response; in that race, + // half-closing our write side can report NotConnected on some platforms. + if let Err(err) = stream.shutdown(Shutdown::Write) + && err.kind() != io::ErrorKind::NotConnected + { + return Err(err).context("failed to shutdown socket writer"); + } let stdout_result = stdout_thread .join() diff --git a/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs b/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs index c6062d50dd..af8fd59226 100644 --- a/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs +++ b/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs @@ -1,12 +1,15 @@ use std::io::ErrorKind; use std::io::Read; use std::io::Write; +use std::process::Command; +use std::process::Stdio; use std::sync::mpsc; use std::thread; use std::time::Duration; +use std::time::Instant; use anyhow::Context; -use assert_cmd::Command; +use anyhow::anyhow; use pretty_assertions::assert_eq; #[cfg(unix)] @@ -17,8 +20,18 @@ use uds_windows::UnixListener; #[test] fn pipes_stdin_and_stdout_through_socket() -> anyhow::Result<()> { + // This test intentionally avoids `read_to_end()` on the server side because + // waiting for EOF can race with socket half-close behavior on slower runners. + // Reading the exact request length keeps the test deterministic. + // + // We also use `std::process::Command` (instead of `assert_cmd`) so we can + // poll/kill on timeout and include incremental server events + stderr in + // failure output, which makes flaky failures actionable to debug. let dir = tempfile::TempDir::new().context("failed to create temp dir")?; let socket_path = dir.path().join("socket"); + let request = b"request"; + let request_path = dir.path().join("request.txt"); + std::fs::write(&request_path, request).context("failed to write child stdin fixture")?; let listener = match UnixListener::bind(&socket_path) { Ok(listener) => listener, Err(err) if err.kind() == ErrorKind::PermissionDenied => { @@ -31,37 +44,103 @@ fn pipes_stdin_and_stdout_through_socket() -> anyhow::Result<()> { }; let (tx, rx) = mpsc::channel(); + let (event_tx, event_rx) = mpsc::channel(); let server_thread = thread::spawn(move || -> anyhow::Result<()> { + let _ = event_tx.send("waiting for accept".to_string()); let (mut connection, _) = listener .accept() .context("failed to accept test connection")?; - let mut received = Vec::new(); + let _ = event_tx.send("accepted connection".to_string()); + let mut received = vec![0; request.len()]; connection - .read_to_end(&mut received) + .read_exact(&mut received) .context("failed to read data from client")?; + let _ = event_tx.send(format!("read {} bytes", received.len())); tx.send(received) - .map_err(|_| anyhow::anyhow!("failed to send received bytes to test thread"))?; + .map_err(|_| anyhow!("failed to send received bytes to test thread"))?; connection .write_all(b"response") .context("failed to write response to client")?; + let _ = event_tx.send("wrote response".to_string()); Ok(()) }); - Command::new(codex_utils_cargo_bin::cargo_bin("codex-stdio-to-uds")?) + let stdin = std::fs::File::open(&request_path).context("failed to open child stdin fixture")?; + let mut child = Command::new(codex_utils_cargo_bin::cargo_bin("codex-stdio-to-uds")?) .arg(&socket_path) - .write_stdin("request") - .assert() - .success() - .stdout("response"); + .stdin(Stdio::from(stdin)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn codex-stdio-to-uds")?; + + let mut child_stdout = child.stdout.take().context("missing child stdout")?; + let mut child_stderr = child.stderr.take().context("missing child stderr")?; + let (stdout_tx, stdout_rx) = mpsc::channel(); + let (stderr_tx, stderr_rx) = mpsc::channel(); + thread::spawn(move || { + let mut stdout = Vec::new(); + let result = child_stdout.read_to_end(&mut stdout).map(|_| stdout); + let _ = stdout_tx.send(result); + }); + thread::spawn(move || { + let mut stderr = Vec::new(); + let result = child_stderr.read_to_end(&mut stderr).map(|_| stderr); + let _ = stderr_tx.send(result); + }); + + let mut server_events = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + while let Ok(event) = event_rx.try_recv() { + server_events.push(event); + } + + if let Some(status) = child.try_wait().context("failed to poll child status")? { + break status; + } + + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + let stderr = stderr_rx + .recv_timeout(Duration::from_secs(1)) + .context("timed out waiting for child stderr after kill")? + .context("failed to read child stderr")?; + anyhow::bail!( + "codex-stdio-to-uds did not exit in time; server events: {:?}; stderr: {}", + server_events, + String::from_utf8_lossy(&stderr).trim_end() + ); + } + + thread::sleep(Duration::from_millis(25)); + }; + + let stdout = stdout_rx + .recv_timeout(Duration::from_secs(1)) + .context("timed out waiting for child stdout")? + .context("failed to read child stdout")?; + let stderr = stderr_rx + .recv_timeout(Duration::from_secs(1)) + .context("timed out waiting for child stderr")? + .context("failed to read child stderr")?; + assert!( + status.success(), + "codex-stdio-to-uds exited with {status}; server events: {:?}; stderr: {}", + server_events, + String::from_utf8_lossy(&stderr).trim_end() + ); + assert_eq!(stdout, b"response"); let received = rx .recv_timeout(Duration::from_secs(1)) .context("server did not receive data in time")?; - assert_eq!(received, b"request"); + assert_eq!(received, request); let server_result = server_thread .join() - .map_err(|_| anyhow::anyhow!("server thread panicked"))?; + .map_err(|_| anyhow!("server thread panicked"))?; server_result.context("server failed")?; Ok(()) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 75646d892d..87b2dc795c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1567,8 +1567,16 @@ impl App { self.chat_widget.thread_name(), ); self.shutdown_current_thread().await; - if let Err(err) = self.server.remove_and_close_all_threads().await { - tracing::warn!(error = %err, "failed to close all threads"); + let report = self + .server + .shutdown_all_threads_bounded(Duration::from_secs(10)) + .await; + if !report.submit_failed.is_empty() || !report.timed_out.is_empty() { + tracing::warn!( + submit_failed = report.submit_failed.len(), + timed_out = report.timed_out.len(), + "failed to close all threads" + ); } let init = crate::chatwidget::ChatWidgetInit { config, @@ -1834,6 +1842,7 @@ impl App { config.clone(), target_session.path.clone(), auth_manager.clone(), + None, ) .await .wrap_err_with(|| { @@ -1871,6 +1880,7 @@ impl App { config.clone(), target_session.path.clone(), false, + None, ) .await .wrap_err_with(|| { @@ -2182,6 +2192,7 @@ impl App { resume_config.clone(), target_session.path.clone(), self.auth_manager.clone(), + None, ) .await { @@ -2250,7 +2261,7 @@ impl App { if path.exists() { match self .server - .fork_thread(usize::MAX, self.config.clone(), path.clone(), false) + .fork_thread(usize::MAX, self.config.clone(), path.clone(), false, None) .await { Ok(forked) => { @@ -2411,6 +2422,9 @@ impl App { url, is_installed, is_enabled, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, }); } AppEvent::OpenUrlInBrowser { url } => { @@ -3686,10 +3700,18 @@ impl App { } async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + // Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless + // enhanced keyboard reporting is available. We only treat those word-motion fallbacks as + // agent-switch shortcuts when the composer is empty so we never steal the expected + // editing behavior for moving across words inside a draft. let allow_agent_word_motion_fallback = !self.enhanced_keys_supported && self.chat_widget.composer_text_with_pending().is_empty(); if self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() + // Alt+Left/Right are also natural word-motion keys in the composer. Keep agent + // fast-switch available only once the draft is empty so editing behavior wins whenever + // there is text on screen. + && self.chat_widget.composer_text_with_pending().is_empty() && previous_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) { if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( @@ -3702,6 +3724,9 @@ impl App { } if self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() + // Mirror the previous-agent rule above: empty drafts may use these keys for thread + // switching, but non-empty drafts keep them for expected word-wise cursor motion. + && self.chat_widget.composer_text_with_pending().is_empty() && next_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) { if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 2e6fab3d24..15596a9d7f 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -1,3 +1,7 @@ +use codex_protocol::ThreadId; +use codex_protocol::approvals::ElicitationAction; +use codex_protocol::mcp::RequestId as McpRequestId; +use codex_protocol::protocol::Op; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -34,6 +38,19 @@ enum AppLinkScreen { InstallConfirmation, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AppLinkSuggestionType { + Install, + Enable, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AppLinkElicitationTarget { + pub(crate) thread_id: ThreadId, + pub(crate) server_name: String, + pub(crate) request_id: McpRequestId, +} + pub(crate) struct AppLinkViewParams { pub(crate) app_id: String, pub(crate) title: String, @@ -42,6 +59,9 @@ pub(crate) struct AppLinkViewParams { pub(crate) url: String, pub(crate) is_installed: bool, pub(crate) is_enabled: bool, + pub(crate) suggest_reason: Option, + pub(crate) suggestion_type: Option, + pub(crate) elicitation_target: Option, } pub(crate) struct AppLinkView { @@ -52,6 +72,9 @@ pub(crate) struct AppLinkView { url: String, is_installed: bool, is_enabled: bool, + suggest_reason: Option, + suggestion_type: Option, + elicitation_target: Option, app_event_tx: AppEventSender, screen: AppLinkScreen, selected_action: usize, @@ -68,6 +91,9 @@ impl AppLinkView { url, is_installed, is_enabled, + suggest_reason, + suggestion_type, + elicitation_target, } = params; Self { app_id, @@ -77,6 +103,9 @@ impl AppLinkView { url, is_installed, is_enabled, + suggest_reason, + suggestion_type, + elicitation_target, app_event_tx, screen: AppLinkScreen::Link, selected_action: 0, @@ -113,6 +142,31 @@ impl AppLinkView { self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1); } + fn is_tool_suggestion(&self) -> bool { + self.elicitation_target.is_some() + } + + fn resolve_elicitation(&self, decision: ElicitationAction) { + let Some(target) = self.elicitation_target.as_ref() else { + return; + }; + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id: target.thread_id, + op: Op::ResolveElicitation { + server_name: target.server_name.clone(), + request_id: target.request_id.clone(), + decision, + content: None, + meta: None, + }, + }); + } + + fn decline_tool_suggestion(&mut self) { + self.resolve_elicitation(ElicitationAction::Decline); + self.complete = true; + } + fn open_chatgpt_link(&mut self) { self.app_event_tx.send(AppEvent::OpenUrlInBrowser { url: self.url.clone(), @@ -127,6 +181,9 @@ impl AppLinkView { self.app_event_tx.send(AppEvent::RefreshConnectors { force_refetch: true, }); + if self.is_tool_suggestion() { + self.resolve_elicitation(ElicitationAction::Accept); + } self.complete = true; } @@ -141,9 +198,40 @@ impl AppLinkView { id: self.app_id.clone(), enabled: self.is_enabled, }); + if self.is_tool_suggestion() { + self.resolve_elicitation(ElicitationAction::Accept); + self.complete = true; + } } fn activate_selected_action(&mut self) { + if self.is_tool_suggestion() { + match self.suggestion_type { + Some(AppLinkSuggestionType::Enable) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_chatgpt_link(), + 1 if self.is_installed => self.toggle_enabled(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.refresh_connectors_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::Install) | None => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_chatgpt_link(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.refresh_connectors_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + } + return; + } + match self.screen { AppLinkScreen::Link => match self.selected_action { 0 => self.open_chatgpt_link(), @@ -181,6 +269,17 @@ impl AppLinkView { } lines.push(Line::from("")); + if let Some(suggest_reason) = self + .suggest_reason + .as_deref() + .map(str::trim) + .filter(|suggest_reason| !suggest_reason.is_empty()) + { + for line in wrap(suggest_reason, usable_width) { + lines.push(Line::from(line.into_owned().italic())); + } + lines.push(Line::from("")); + } if self.is_installed { for line in wrap("Use $ to insert this app into the prompt.", usable_width) { lines.push(Line::from(line.into_owned())); @@ -366,6 +465,9 @@ impl BottomPaneView for AppLinkView { } fn on_ctrl_c(&mut self) -> CancellationEvent { + if self.is_tool_suggestion() { + self.resolve_elicitation(ElicitationAction::Decline); + } self.complete = true; CancellationEvent::Handled } @@ -447,8 +549,40 @@ mod tests { use super::*; use crate::app_event::AppEvent; use crate::render::renderable::Renderable; + use insta::assert_snapshot; use tokio::sync::mpsc::unbounded_channel; + fn suggestion_target() -> AppLinkElicitationTarget { + AppLinkElicitationTarget { + thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001") + .expect("valid thread id"), + server_name: "codex_apps".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + } + } + + fn render_snapshot(view: &AppLinkView, area: Rect) -> String { + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| { + let symbol = buf[(x, y)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") + } + #[test] fn installed_app_has_toggle_action() { let (tx_raw, _rx) = unbounded_channel::(); @@ -462,6 +596,9 @@ mod tests { url: "https://example.test/notion".to_string(), is_installed: true, is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, }, tx, ); @@ -485,6 +622,9 @@ mod tests { url: "https://example.test/notion".to_string(), is_installed: true, is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, }, tx, ); @@ -521,6 +661,9 @@ mod tests { url: url_like.to_string(), is_installed: true, is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, }, tx, ); @@ -561,6 +704,9 @@ mod tests { url: url.to_string(), is_installed: true, is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, }, tx, ); @@ -593,4 +739,206 @@ mod tests { "expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}" ); } + + #[test] + fn install_tool_suggestion_resolves_elicitation_after_confirmation() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Install this app in your browser, then return here.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: false, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Install), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::OpenUrlInBrowser { url }) => { + assert_eq!(url, "https://example.test/google-calendar".to_string()); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert_eq!(view.screen, AppLinkScreen::InstallConfirmation); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::RefreshConnectors { force_refetch }) => { + assert!(force_refetch); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, suggestion_target().thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "codex_apps".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + decision: ElicitationAction::Accept, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(view.is_complete()); + } + + #[test] + fn declined_tool_suggestion_resolves_elicitation_decline() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: None, + instructions: "Install this app in your browser, then return here.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: false, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Install), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, suggestion_target().thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "codex_apps".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + decision: ElicitationAction::Decline, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(view.is_complete()); + } + + #[test] + fn enable_tool_suggestion_resolves_elicitation_after_enable() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Enable this app to use it for the current request.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: true, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Enable), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::SetAppEnabled { id, enabled }) => { + assert_eq!(id, "connector_google_calendar"); + assert!(enabled); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, suggestion_target().thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "codex_apps".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + decision: ElicitationAction::Accept, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(view.is_complete()); + } + + #[test] + fn install_suggestion_with_reason_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Install this app in your browser, then return here.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: false, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Install), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert_snapshot!( + "app_link_view_install_suggestion_with_reason", + render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72))) + ); + } + + #[test] + fn enable_suggestion_with_reason_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Enable this app to use it for the current request.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: true, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Enable), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert_snapshot!( + "app_link_view_enable_suggestion_with_reason", + render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72))) + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 43da1c0b81..975b8701d2 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -40,6 +40,8 @@ use crate::bottom_pane::selection_popup_common::menu_surface_padding_height; use crate::bottom_pane::selection_popup_common::render_menu_surface; use crate::bottom_pane::selection_popup_common::render_rows; use crate::render::renderable::Renderable; +use crate::text_formatting::format_json_compact; +use crate::text_formatting::truncate_text; const ANSWER_PLACEHOLDER: &str = "Type your answer"; const OPTIONAL_ANSWER_PLACEHOLDER: &str = "Type your answer (optional)"; @@ -54,9 +56,20 @@ const APPROVAL_DECLINE_VALUE: &str = "decline"; const APPROVAL_CANCEL_VALUE: &str = "cancel"; const APPROVAL_META_KIND_KEY: &str = "codex_approval_kind"; const APPROVAL_META_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call"; +const APPROVAL_META_KIND_TOOL_SUGGESTION: &str = "tool_suggestion"; const APPROVAL_PERSIST_KEY: &str = "persist"; const APPROVAL_PERSIST_SESSION_VALUE: &str = "session"; const APPROVAL_PERSIST_ALWAYS_VALUE: &str = "always"; +const APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params"; +const APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display"; +const APPROVAL_TOOL_PARAM_DISPLAY_LIMIT: usize = 3; +const APPROVAL_TOOL_PARAM_VALUE_TRUNCATE_GRAPHEMES: usize = 60; +const TOOL_TYPE_KEY: &str = "tool_type"; +const TOOL_ID_KEY: &str = "tool_id"; +const TOOL_NAME_KEY: &str = "tool_name"; +const TOOL_SUGGEST_SUGGEST_TYPE_KEY: &str = "suggest_type"; +const TOOL_SUGGEST_REASON_KEY: &str = "suggest_reason"; +const TOOL_SUGGEST_INSTALL_URL_KEY: &str = "install_url"; #[derive(Clone, PartialEq, Default)] struct ComposerDraft { @@ -117,14 +130,44 @@ enum McpServerElicitationResponseMode { ApprovalAction, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolSuggestionToolType { + Connector, + Plugin, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolSuggestionType { + Install, + Enable, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ToolSuggestionRequest { + pub(crate) tool_type: ToolSuggestionToolType, + pub(crate) suggest_type: ToolSuggestionType, + pub(crate) suggest_reason: String, + pub(crate) tool_id: String, + pub(crate) tool_name: String, + pub(crate) install_url: String, +} + +#[derive(Clone, Debug, PartialEq)] +struct McpToolApprovalDisplayParam { + name: String, + value: Value, +} + #[derive(Clone, Debug, PartialEq)] pub(crate) struct McpServerElicitationFormRequest { thread_id: ThreadId, server_name: String, request_id: McpRequestId, message: String, + approval_display_params: Vec, response_mode: McpServerElicitationResponseMode, fields: Vec, + tool_suggestion: Option, } #[derive(Default)] @@ -170,6 +213,7 @@ impl McpServerElicitationFormRequest { return None; }; + let tool_suggestion = parse_tool_suggestion_request(meta.as_ref()); let is_tool_approval = meta .as_ref() .and_then(Value::as_object) @@ -185,10 +229,17 @@ impl McpServerElicitationFormRequest { }); let is_tool_approval_action = is_tool_approval && (requested_schema.is_null() || is_empty_object_schema); + let approval_display_params = if is_tool_approval_action { + parse_tool_approval_display_params(meta.as_ref()) + } else { + Vec::new() + }; - let (response_mode, fields) = if requested_schema.is_null() - || (is_tool_approval && is_empty_object_schema) + let (response_mode, fields) = if tool_suggestion.is_some() + && (requested_schema.is_null() || is_empty_object_schema) { + (McpServerElicitationResponseMode::FormContent, Vec::new()) + } else if requested_schema.is_null() || (is_tool_approval && is_empty_object_schema) { let mut options = vec![McpServerElicitationOption { label: "Allow".to_string(), description: Some("Run the tool and continue.".to_string()), @@ -264,10 +315,66 @@ impl McpServerElicitationFormRequest { server_name: request.server_name, request_id: request.id, message, + approval_display_params, response_mode, fields, + tool_suggestion, }) } + + pub(crate) fn tool_suggestion(&self) -> Option<&ToolSuggestionRequest> { + self.tool_suggestion.as_ref() + } + + pub(crate) fn thread_id(&self) -> ThreadId { + self.thread_id + } + + pub(crate) fn server_name(&self) -> &str { + self.server_name.as_str() + } + + pub(crate) fn request_id(&self) -> &McpRequestId { + &self.request_id + } +} + +fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option { + let meta = meta?.as_object()?; + if meta.get(APPROVAL_META_KIND_KEY).and_then(Value::as_str) + != Some(APPROVAL_META_KIND_TOOL_SUGGESTION) + { + return None; + } + + let tool_type = match meta.get(TOOL_TYPE_KEY).and_then(Value::as_str) { + Some("connector") => ToolSuggestionToolType::Connector, + Some("plugin") => ToolSuggestionToolType::Plugin, + _ => return None, + }; + let suggest_type = match meta + .get(TOOL_SUGGEST_SUGGEST_TYPE_KEY) + .and_then(Value::as_str) + { + Some("install") => ToolSuggestionType::Install, + Some("enable") => ToolSuggestionType::Enable, + _ => return None, + }; + + Some(ToolSuggestionRequest { + tool_type, + suggest_type, + suggest_reason: meta + .get(TOOL_SUGGEST_REASON_KEY) + .and_then(Value::as_str)? + .to_string(), + tool_id: meta.get(TOOL_ID_KEY).and_then(Value::as_str)?.to_string(), + tool_name: meta.get(TOOL_NAME_KEY).and_then(Value::as_str)?.to_string(), + install_url: meta + .get(TOOL_SUGGEST_INSTALL_URL_KEY) + .and_then(Value::as_str)? + .to_string(), + }) } fn tool_approval_supports_persist_mode(meta: Option<&Value>, expected_mode: &str) -> bool { @@ -288,6 +395,99 @@ fn tool_approval_supports_persist_mode(meta: Option<&Value>, expected_mode: &str } } +fn parse_tool_approval_display_params(meta: Option<&Value>) -> Vec { + let Some(meta) = meta.and_then(Value::as_object) else { + return Vec::new(); + }; + + let display_params = meta + .get(APPROVAL_TOOL_PARAMS_DISPLAY_KEY) + .and_then(Value::as_array) + .map(|display_params| { + display_params + .iter() + .filter_map(parse_tool_approval_display_param) + .collect::>() + }) + .unwrap_or_default(); + if !display_params.is_empty() { + return display_params; + } + + let mut fallback_params = meta + .get(APPROVAL_TOOL_PARAMS_KEY) + .and_then(Value::as_object) + .map(|tool_params| { + tool_params + .iter() + .map(|(name, value)| McpToolApprovalDisplayParam { + name: name.clone(), + value: value.clone(), + }) + .collect::>() + }) + .unwrap_or_default(); + fallback_params.sort_by(|left, right| left.name.cmp(&right.name)); + fallback_params +} + +fn parse_tool_approval_display_param(value: &Value) -> Option { + let value = value.as_object()?; + let name = value.get("name")?.as_str()?.trim(); + if name.is_empty() { + return None; + } + Some(McpToolApprovalDisplayParam { + name: name.to_string(), + value: value.get("value")?.clone(), + }) +} + +fn format_tool_approval_display_message( + message: &str, + approval_display_params: &[McpToolApprovalDisplayParam], +) -> String { + let message = message.trim(); + if approval_display_params.is_empty() { + return message.to_string(); + } + + let mut sections = Vec::new(); + if !message.is_empty() { + sections.push(message.to_string()); + } + let param_lines = approval_display_params + .iter() + .take(APPROVAL_TOOL_PARAM_DISPLAY_LIMIT) + .map(format_tool_approval_display_param_line) + .collect::>(); + if !param_lines.is_empty() { + sections.push(param_lines.join("\n")); + } + let mut message = sections.join("\n\n"); + message.push('\n'); + message +} + +fn format_tool_approval_display_param_line(param: &McpToolApprovalDisplayParam) -> String { + format!( + "{}: {}", + param.name, + format_tool_approval_display_param_value(¶m.value) + ) +} + +fn format_tool_approval_display_param_value(value: &Value) -> String { + let formatted = match value { + Value::String(text) => text.split_whitespace().collect::>().join(" "), + _ => { + let compact_json = value.to_string(); + format_json_compact(&compact_json).unwrap_or(compact_json) + } + }; + truncate_text(&formatted, APPROVAL_TOOL_PARAM_VALUE_TRUNCATE_GRAPHEMES) +} + fn parse_fields_from_schema(requested_schema: &Value) -> Option> { let schema = requested_schema.as_object()?; if schema.get("type").and_then(Value::as_str) != Some("object") { @@ -691,12 +891,16 @@ impl McpServerElicitationOverlay { } fn current_prompt_text(&self) -> String { + let request_message = format_tool_approval_display_message( + &self.request.message, + &self.request.approval_display_params, + ); let Some(field) = self.current_field() else { - return self.request.message.clone(); + return request_message; }; let mut sections = Vec::new(); - if !self.request.message.trim().is_empty() { - sections.push(self.request.message.trim().to_string()); + if !request_message.trim().is_empty() { + sections.push(request_message); } let field_prompt = if field.label.trim().is_empty() || field.prompt.trim().is_empty() @@ -1461,7 +1665,11 @@ mod tests { }) } - fn tool_approval_meta(persist_modes: &[&str]) -> Option { + fn tool_approval_meta( + persist_modes: &[&str], + tool_params: Option, + tool_params_display: Option>, + ) -> Option { let mut meta = serde_json::Map::from_iter([( APPROVAL_META_KIND_KEY.to_string(), Value::String(APPROVAL_META_KIND_MCP_TOOL_CALL.to_string()), @@ -1477,6 +1685,25 @@ mod tests { ), ); } + if let Some(tool_params) = tool_params { + meta.insert(APPROVAL_TOOL_PARAMS_KEY.to_string(), tool_params); + } + if let Some(tool_params_display) = tool_params_display { + meta.insert( + APPROVAL_TOOL_PARAMS_DISPLAY_KEY.to_string(), + Value::Array( + tool_params_display + .into_iter() + .map(|(name, value)| { + serde_json::json!({ + "name": name, + "value": value, + }) + }) + .collect(), + ), + ); + } Some(Value::Object(meta)) } @@ -1528,6 +1755,7 @@ mod tests { server_name: "server-1".to_string(), request_id: McpRequestId::String("request-1".to_string()), message: "Allow this request?".to_string(), + approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::FormContent, fields: vec![McpServerElicitationField { id: "confirmed".to_string(), @@ -1550,6 +1778,7 @@ mod tests { default_idx: None, }, }], + tool_suggestion: None, } ); } @@ -1592,6 +1821,7 @@ mod tests { server_name: "server-1".to_string(), request_id: McpRequestId::String("request-1".to_string()), message: "Allow this request?".to_string(), + approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::ApprovalAction, fields: vec![McpServerElicitationField { id: APPROVAL_FIELD_ID.to_string(), @@ -1621,6 +1851,7 @@ mod tests { default_idx: Some(0), }, }], + tool_suggestion: None, } ); } @@ -1633,7 +1864,7 @@ mod tests { form_request( "Allow this request?", empty_object_schema(), - tool_approval_meta(&[]), + tool_approval_meta(&[], None, None), ), ) .expect("expected approval fallback"); @@ -1645,6 +1876,7 @@ mod tests { server_name: "server-1".to_string(), request_id: McpRequestId::String("request-1".to_string()), message: "Allow this request?".to_string(), + approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::ApprovalAction, fields: vec![McpServerElicitationField { id: APPROVAL_FIELD_ID.to_string(), @@ -1667,10 +1899,44 @@ mod tests { default_idx: Some(0), }, }], + tool_suggestion: None, } ); } + #[test] + fn tool_suggestion_meta_is_parsed_into_request_payload() { + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Suggest Google Calendar", + empty_object_schema(), + Some(serde_json::json!({ + "codex_approval_kind": "tool_suggestion", + "tool_type": "connector", + "suggest_type": "install", + "suggest_reason": "Plan and reference events from your calendar", + "tool_id": "connector_2128aebfecb84f64a069897515042a44", + "tool_name": "Google Calendar", + "install_url": "https://example.test/google-calendar", + })), + ), + ) + .expect("expected tool suggestion form"); + + assert_eq!( + request.tool_suggestion(), + Some(&ToolSuggestionRequest { + tool_type: ToolSuggestionToolType::Connector, + suggest_type: ToolSuggestionType::Install, + suggest_reason: "Plan and reference events from your calendar".to_string(), + tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + tool_name: "Google Calendar".to_string(), + install_url: "https://example.test/google-calendar".to_string(), + }) + ); + } + #[test] fn empty_unmarked_schema_falls_back() { let request = McpServerElicitationFormRequest::from_event( @@ -1681,6 +1947,43 @@ mod tests { assert_eq!(request, None); } + #[test] + fn tool_approval_display_params_prefer_explicit_display_order() { + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Allow Calendar to create an event", + empty_object_schema(), + tool_approval_meta( + &[], + Some(serde_json::json!({ + "zeta": 3, + "alpha": 1, + })), + Some(vec![ + ("Calendar", Value::String("primary".to_string())), + ("Title", Value::String("Roadmap review".to_string())), + ]), + ), + ), + ) + .expect("expected approval fallback"); + + assert_eq!( + request.approval_display_params, + vec![ + McpToolApprovalDisplayParam { + name: "Calendar".to_string(), + value: Value::String("primary".to_string()), + }, + McpToolApprovalDisplayParam { + name: "Title".to_string(), + value: Value::String("Roadmap review".to_string()), + }, + ] + ); + } + #[test] fn submit_sends_accept_with_typed_content() { let (tx, mut rx) = test_sender(); @@ -1741,10 +2044,14 @@ mod tests { form_request( "Allow this request?", empty_object_schema(), - tool_approval_meta(&[ - APPROVAL_PERSIST_SESSION_VALUE, - APPROVAL_PERSIST_ALWAYS_VALUE, - ]), + tool_approval_meta( + &[ + APPROVAL_PERSIST_SESSION_VALUE, + APPROVAL_PERSIST_ALWAYS_VALUE, + ], + None, + None, + ), ), ) .expect("expected approval fallback"); @@ -1788,10 +2095,14 @@ mod tests { form_request( "Allow this request?", empty_object_schema(), - tool_approval_meta(&[ - APPROVAL_PERSIST_SESSION_VALUE, - APPROVAL_PERSIST_ALWAYS_VALUE, - ]), + tool_approval_meta( + &[ + APPROVAL_PERSIST_SESSION_VALUE, + APPROVAL_PERSIST_ALWAYS_VALUE, + ], + None, + None, + ), ), ) .expect("expected approval fallback"); @@ -1981,7 +2292,7 @@ mod tests { form_request( "Allow this request?", empty_object_schema(), - tool_approval_meta(&[]), + tool_approval_meta(&[], None, None), ), ) .expect("expected approval fallback"); @@ -2001,10 +2312,14 @@ mod tests { form_request( "Allow this request?", empty_object_schema(), - tool_approval_meta(&[ - APPROVAL_PERSIST_SESSION_VALUE, - APPROVAL_PERSIST_ALWAYS_VALUE, - ]), + tool_approval_meta( + &[ + APPROVAL_PERSIST_SESSION_VALUE, + APPROVAL_PERSIST_ALWAYS_VALUE, + ], + None, + None, + ), ), ) .expect("expected approval fallback"); @@ -2015,4 +2330,41 @@ mod tests { render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) ); } + + #[test] + fn approval_form_tool_approval_with_param_summary_snapshot() { + let (tx, _rx) = test_sender(); + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Allow Calendar to create an event", + empty_object_schema(), + tool_approval_meta( + &[], + Some(serde_json::json!({ + "calendar_id": "primary", + "title": "Roadmap review", + "notes": "This is a deliberately long note that should truncate before it turns the approval body into a giant wall of text in the TUI overlay.", + "ignored_after_limit": "fourth param", + })), + Some(vec![ + ("Calendar", Value::String("primary".to_string())), + ("Title", Value::String("Roadmap review".to_string())), + ( + "Notes", + Value::String("This is a deliberately long note that should truncate before it turns the approval body into a giant wall of text in the TUI overlay.".to_string()), + ), + ("Ignored", Value::String("fourth param".to_string())), + ]), + ), + ), + ) + .expect("expected approval fallback"); + let overlay = McpServerElicitationOverlay::new(request, tx, true, false, false); + + insta::assert_snapshot!( + "mcp_server_elicitation_approval_form_with_param_summary", + render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f9c34222fb..2d66002418 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -47,6 +47,8 @@ mod mcp_server_elicitation; mod multi_select_picker; mod request_user_input; mod status_line_setup; +pub(crate) use app_link_view::AppLinkElicitationTarget; +pub(crate) use app_link_view::AppLinkSuggestionType; pub(crate) use app_link_view::AppLinkView; pub(crate) use app_link_view::AppLinkViewParams; pub(crate) use approval_overlay::ApprovalOverlay; @@ -963,6 +965,52 @@ impl BottomPane { request }; + if let Some(tool_suggestion) = request.tool_suggestion() { + let suggestion_type = match tool_suggestion.suggest_type { + mcp_server_elicitation::ToolSuggestionType::Install => { + AppLinkSuggestionType::Install + } + mcp_server_elicitation::ToolSuggestionType::Enable => AppLinkSuggestionType::Enable, + }; + let is_installed = matches!( + tool_suggestion.suggest_type, + mcp_server_elicitation::ToolSuggestionType::Enable + ); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: tool_suggestion.tool_id.clone(), + title: tool_suggestion.tool_name.clone(), + description: None, + instructions: match suggestion_type { + AppLinkSuggestionType::Install => { + "Install this app in your browser, then return here.".to_string() + } + AppLinkSuggestionType::Enable => { + "Enable this app to use it for the current request.".to_string() + } + }, + url: tool_suggestion.install_url.clone(), + is_installed, + is_enabled: false, + suggest_reason: Some(tool_suggestion.suggest_reason.clone()), + suggestion_type: Some(suggestion_type), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id: request.thread_id(), + server_name: request.server_name().to_string(), + request_id: request.request_id().clone(), + }), + }, + self.app_event_tx.clone(), + ); + self.pause_status_timer_for_modal(); + self.set_composer_input_enabled( + false, + Some("Respond to the tool suggestion to continue.".to_string()), + ); + self.push_view(Box::new(view)); + return; + } + let modal = McpServerElicitationOverlay::new( request, self.app_event_tx.clone(), diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_enable_suggestion_with_reason.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_enable_suggestion_with_reason.snap new file mode 100644 index 0000000000..94980ff650 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_enable_suggestion_with_reason.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Google Calendar + Plan events and schedules. + + Plan and reference events from your calendar + + Use $ to insert this app into the prompt. + + Enable this app to use it for the current request. + Newly installed apps can take a few minutes to appear in /apps. + + + › 1. Manage on ChatGPT + 2. Enable app + 3. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_install_suggestion_with_reason.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_install_suggestion_with_reason.snap new file mode 100644 index 0000000000..ac47f87418 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_install_suggestion_with_reason.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Google Calendar + Plan events and schedules. + + Plan and reference events from your calendar + + Install this app in your browser, then return here. + Newly installed apps can take a few minutes to appear in /apps. + After installed, use $ to insert this app into the prompt. + + + › 1. Install on ChatGPT + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_param_summary.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_param_summary.snap new file mode 100644 index 0000000000..0ac8f529ad --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_param_summary.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/mcp_server_elicitation.rs +expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))" +--- + + Field 1/1 + Allow Calendar to create an event + + Calendar: primary + Title: Roadmap review + Notes: This is a deliberately long note that should truncate bef... + + › 1. Allow Run the tool and continue. + 2. Cancel Cancel this tool call + + + + + enter to submit | esc to cancel diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index bb6184622b..2c4925fff8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2041,6 +2041,8 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { new_agent_nickname: Some("Robie".to_string()), new_agent_role: Some("explorer".to_string()), prompt: "Explore the repo".to_string(), + model: "gpt-5".to_string(), + reasoning_effort: ReasoningEffortConfig::High, status: AgentStatus::PendingInit, }), }); diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index c68bf1970f..00a1c0429c 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -121,8 +121,10 @@ fn previous_agent_word_motion_fallback( key_event: KeyEvent, allow_word_motion_fallback: bool, ) -> bool { - // macOS terminals often send Option+b/f as word-motion keys instead of Option+arrow events - // unless enhanced keyboard reporting is enabled. + // Some terminals, especially on macOS, send Option+b/f as word-motion keys instead of + // Option+arrow events unless enhanced keyboard reporting is enabled. Callers should only + // enable this fallback when the composer is empty so draft editing retains the expected + // word-wise motion behavior. allow_word_motion_fallback && matches!( key_event, @@ -145,8 +147,10 @@ fn previous_agent_word_motion_fallback( #[cfg(target_os = "macos")] fn next_agent_word_motion_fallback(key_event: KeyEvent, allow_word_motion_fallback: bool) -> bool { - // macOS terminals often send Option+b/f as word-motion keys instead of Option+arrow events - // unless enhanced keyboard reporting is enabled. + // Some terminals, especially on macOS, send Option+b/f as word-motion keys instead of + // Option+arrow events unless enhanced keyboard reporting is enabled. Callers should only + // enable this fallback when the composer is empty so draft editing retains the expected + // word-wise motion behavior. allow_word_motion_fallback && matches!( key_event, @@ -179,6 +183,7 @@ pub(crate) fn spawn_end( new_agent_role, prompt, status: _, + .. } = ev; let title = match new_thread_id { @@ -597,6 +602,8 @@ mod tests { new_agent_nickname: Some("Robie".to_string()), new_agent_role: Some("explorer".to_string()), prompt: "Compute 11! and reply with just the integer result.".to_string(), + model: "gpt-5".to_string(), + reasoning_effort: ReasoningEffortConfig::High, status: AgentStatus::PendingInit, }, Some(&SpawnRequestSummary { @@ -671,7 +678,15 @@ mod tests { #[cfg(target_os = "macos")] #[test] - fn agent_shortcut_matches_option_arrow_word_motion_fallbacks() { + fn agent_shortcut_matches_option_arrow_word_motion_fallbacks_only_when_allowed() { + assert!(previous_agent_shortcut_matches( + KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), + false, + )); + assert!(next_agent_shortcut_matches( + KeyEvent::new(KeyCode::Right, KeyModifiers::ALT), + false, + )); assert!(previous_agent_shortcut_matches( KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), true, @@ -690,6 +705,27 @@ mod tests { )); } + #[cfg(not(target_os = "macos"))] + #[test] + fn agent_shortcut_matches_option_arrows_only() { + assert!(previous_agent_shortcut_matches( + KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::ALT,), + false + )); + assert!(next_agent_shortcut_matches( + KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::ALT,), + false + )); + assert!(!previous_agent_shortcut_matches( + KeyEvent::new(KeyCode::Char('b'), crossterm::event::KeyModifiers::ALT,), + false + )); + assert!(!next_agent_shortcut_matches( + KeyEvent::new(KeyCode::Char('f'), crossterm::event::KeyModifiers::ALT,), + false + )); + } + #[test] fn title_styles_nickname_and_role() { let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001") @@ -704,6 +740,8 @@ mod tests { new_agent_nickname: Some("Robie".to_string()), new_agent_role: Some("explorer".to_string()), prompt: String::new(), + model: "gpt-5".to_string(), + reasoning_effort: ReasoningEffortConfig::High, status: AgentStatus::PendingInit, }, Some(&SpawnRequestSummary { diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 4cdfeced5b..c2719b1cb4 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -16,7 +16,7 @@ const FAST_TOOLTIP: &str = "*New* Use **/fast** to enable our fastest inference const OTHER_TOOLTIP: &str = "*New* Build faster with the **Codex App**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true"; const OTHER_TOOLTIP_NON_MAC: &str = "*New* Build faster with Codex."; const FREE_GO_TOOLTIP: &str = - "*New* Codex is included in your plan for free through *March 2nd* – let’s build together."; + "*New* For a limited time, Codex is included in your plan for free – let’s build together."; const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); diff --git a/codex-rs/utils/cli/src/config_override.rs b/codex-rs/utils/cli/src/config_override.rs index 1592563a0b..41be3ca6b6 100644 --- a/codex-rs/utils/cli/src/config_override.rs +++ b/codex-rs/utils/cli/src/config_override.rs @@ -89,8 +89,8 @@ impl CliConfigOverrides { } fn canonicalize_override_key(key: &str) -> String { - if key == "use_linux_sandbox_bwrap" { - "features.use_linux_sandbox_bwrap".to_string() + if key == "use_legacy_landlock" { + "features.use_legacy_landlock".to_string() } else { key.to_string() } @@ -181,12 +181,12 @@ mod tests { } #[test] - fn canonicalizes_use_linux_sandbox_bwrap_alias() { + fn canonicalizes_use_legacy_landlock_alias() { let overrides = CliConfigOverrides { - raw_overrides: vec!["use_linux_sandbox_bwrap=true".to_string()], + raw_overrides: vec!["use_legacy_landlock=true".to_string()], }; let parsed = overrides.parse_overrides().expect("parse_overrides"); - assert_eq!(parsed[0].0.as_str(), "features.use_linux_sandbox_bwrap"); + assert_eq!(parsed[0].0.as_str(), "features.use_legacy_landlock"); assert_eq!(parsed[0].1.as_bool(), Some(true)); } diff --git a/docs/js_repl.md b/docs/js_repl.md index 8c104df1e7..2976784fc8 100644 --- a/docs/js_repl.md +++ b/docs/js_repl.md @@ -74,6 +74,8 @@ imported local file. They are not resolved relative to the imported file's locat `js_repl` exposes these globals: +- `codex.cwd`: REPL working directory path. +- `codex.homeDir`: effective home directory path from the kernel environment. - `codex.tmpDir`: per-session scratch directory path. - `codex.tool(name, args?)`: executes a normal Codex tool call from inside `js_repl` (including shell tools like `shell` / `shell_command` when available). - `codex.emitImage(imageLike)`: explicitly adds one image to the outer `js_repl` function output each time you call it. @@ -82,8 +84,9 @@ imported local file. They are not resolved relative to the imported file's locat - Nested `codex.tool(...)` outputs stay inside JavaScript unless you emit them explicitly. - `codex.emitImage(...)` accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object that contains exactly one image and no text. Call it multiple times if you want to emit multiple images. - `codex.emitImage(...)` rejects mixed text-and-image content. -- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: "jpeg", quality: 85 }), mimeType: "image/jpeg" })`. -- Example of sharing a local image tool result: `await codex.emitImage(codex.tool("view_image", { path: "/absolute/path" }))`. +- Request full-resolution image processing with `detail: "original"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: "original"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents. +- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: "jpeg", quality: 85 }), mimeType: "image/jpeg", detail: "original" })`. +- Example of sharing a local image tool result: `await codex.emitImage(codex.tool("view_image", { path: "/absolute/path", detail: "original" }))`. - When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits. Avoid writing directly to `process.stdout` / `process.stderr` / `process.stdin`; the kernel uses a JSON-line transport over stdio. diff --git a/scripts/check_blob_size.py b/scripts/check_blob_size.py new file mode 100755 index 0000000000..455145f18e --- /dev/null +++ b/scripts/check_blob_size.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +DEFAULT_MAX_BYTES = 500 * 1024 + + +@dataclass(frozen=True) +class ChangedBlob: + path: str + size_bytes: int + is_allowlisted: bool + is_binary: bool + + +def run_git(*args: str) -> str: + result = subprocess.run( + ["git", *args], + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +def load_allowlist(path: Path) -> set[str]: + allowlist: set[str] = set() + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].strip() + if line: + allowlist.add(line) + return allowlist + + +def get_changed_paths(base: str, head: str) -> list[str]: + output = run_git( + "diff", + "--name-only", + "--diff-filter=AM", + "--no-renames", + "-z", + base, + head, + ) + return [path for path in output.split("\0") if path] + + +def is_binary_change(base: str, head: str, path: str) -> bool: + output = run_git( + "diff", + "--numstat", + "--diff-filter=AM", + "--no-renames", + base, + head, + "--", + path, + ).strip() + if not output: + return False + + added, deleted, _ = output.split("\t", 2) + return added == "-" and deleted == "-" + + +def blob_size(commit: str, path: str) -> int: + return int(run_git("cat-file", "-s", f"{commit}:{path}").strip()) + + +def collect_changed_blobs(base: str, head: str, allowlist: set[str]) -> list[ChangedBlob]: + blobs: list[ChangedBlob] = [] + for path in get_changed_paths(base, head): + blobs.append( + ChangedBlob( + path=path, + size_bytes=blob_size(head, path), + is_allowlisted=path in allowlist, + is_binary=is_binary_change(base, head, path), + ) + ) + return blobs + + +def format_kib(size_bytes: int) -> str: + return f"{size_bytes / 1024:.1f} KiB" + + +def write_step_summary( + max_bytes: int, + blobs: list[ChangedBlob], + violations: list[ChangedBlob], +) -> None: + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + + lines = [ + "## Blob Size Policy", + "", + f"Default max: `{max_bytes}` bytes ({format_kib(max_bytes)})", + f"Changed files checked: `{len(blobs)}`", + f"Violations: `{len(violations)}`", + "", + ] + + if blobs: + lines.extend( + [ + "| Path | Kind | Size | Status |", + "| --- | --- | ---: | --- |", + ] + ) + for blob in blobs: + status = "allowlisted" if blob.is_allowlisted else "ok" + if blob in violations: + status = "blocked" + kind = "binary" if blob.is_binary else "non-binary" + lines.append( + f"| `{blob.path}` | {kind} | `{blob.size_bytes}` bytes ({format_kib(blob.size_bytes)}) | {status} |" + ) + else: + lines.append("No changed files were detected.") + + lines.append("") + Path(summary_path).write_text("\n".join(lines), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Fail if changed blobs exceed the configured size budget." + ) + parser.add_argument("--base", required=True, help="Base git revision to diff against.") + parser.add_argument("--head", required=True, help="Head git revision to inspect.") + parser.add_argument( + "--max-bytes", + type=int, + default=DEFAULT_MAX_BYTES, + help=f"Maximum allowed blob size in bytes. Default: {DEFAULT_MAX_BYTES}.", + ) + parser.add_argument( + "--allowlist", + type=Path, + required=True, + help="Path to the newline-delimited allowlist file.", + ) + args = parser.parse_args() + + allowlist = load_allowlist(args.allowlist) + blobs = collect_changed_blobs(args.base, args.head, allowlist) + violations = [ + blob for blob in blobs if blob.size_bytes > args.max_bytes and not blob.is_allowlisted + ] + + write_step_summary(args.max_bytes, blobs, violations) + + if not blobs: + print("No changed files were detected.") + return 0 + + print(f"Checked {len(blobs)} changed file(s) against the {args.max_bytes}-byte limit.") + for blob in blobs: + status = "allowlisted" if blob.is_allowlisted else "ok" + if blob in violations: + status = "blocked" + kind = "binary" if blob.is_binary else "non-binary" + print( + f"- {blob.path}: {blob.size_bytes} bytes ({format_kib(blob.size_bytes)}) [{kind}, {status}]" + ) + + if violations: + print("\nFile(s) exceed the configured limit:") + for blob in violations: + print(f"- {blob.path}: {blob.size_bytes} bytes > {args.max_bytes} bytes") + print( + "\nIf one of these is a real checked-in asset we want to keep, add its " + "repo-relative path to .github/blob-size-allowlist.txt. Otherwise, " + "shrink it or keep it out of git." + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sdk/python-runtime/README.md b/sdk/python-runtime/README.md new file mode 100644 index 0000000000..0e7b26d5fc --- /dev/null +++ b/sdk/python-runtime/README.md @@ -0,0 +1,9 @@ +# Codex CLI Runtime for Python SDK + +Platform-specific runtime package consumed by the published `codex-app-server-sdk`. + +This package is staged during release so the SDK can pin an exact Codex CLI +version without checking platform binaries into the repo. + +`codex-cli-bin` is intentionally wheel-only. Do not build or publish an sdist +for this package. diff --git a/sdk/python-runtime/hatch_build.py b/sdk/python-runtime/hatch_build.py new file mode 100644 index 0000000000..319e6973fb --- /dev/null +++ b/sdk/python-runtime/hatch_build.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class RuntimeBuildHook(BuildHookInterface): + def initialize(self, version: str, build_data: dict[str, object]) -> None: + del version + if self.target_name == "sdist": + raise RuntimeError( + "codex-cli-bin is wheel-only; build and publish platform wheels only." + ) + + build_data["pure_python"] = False + build_data["infer_tag"] = True diff --git a/sdk/python-runtime/pyproject.toml b/sdk/python-runtime/pyproject.toml new file mode 100644 index 0000000000..761dccbb9f --- /dev/null +++ b/sdk/python-runtime/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "codex-cli-bin" +version = "0.0.0-dev" +description = "Pinned Codex CLI runtime for the Python SDK" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "Apache-2.0" } +authors = [{ name = "OpenAI" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +[project.urls] +Homepage = "https://github.com/openai/codex" +Repository = "https://github.com/openai/codex" +Issues = "https://github.com/openai/codex/issues" + +[tool.hatch.build] +exclude = [ + ".venv/**", + ".pytest_cache/**", + "dist/**", + "build/**", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/codex_cli_bin"] +include = ["src/codex_cli_bin/bin/**"] + +[tool.hatch.build.targets.wheel.hooks.custom] + +[tool.hatch.build.targets.sdist] + +[tool.hatch.build.targets.sdist.hooks.custom] diff --git a/sdk/python-runtime/src/codex_cli_bin/__init__.py b/sdk/python-runtime/src/codex_cli_bin/__init__.py new file mode 100644 index 0000000000..8059d39211 --- /dev/null +++ b/sdk/python-runtime/src/codex_cli_bin/__init__.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import os +from pathlib import Path + +PACKAGE_NAME = "codex-cli-bin" + + +def bundled_codex_path() -> Path: + exe = "codex.exe" if os.name == "nt" else "codex" + path = Path(__file__).resolve().parent / "bin" / exe + if not path.is_file(): + raise FileNotFoundError( + f"{PACKAGE_NAME} is installed but missing its packaged codex binary at {path}" + ) + return path + + +__all__ = ["PACKAGE_NAME", "bundled_codex_path"] diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 0000000000..ef3abdf630 --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,95 @@ +# Codex App Server Python SDK (Experimental) + +Experimental Python SDK for `codex app-server` JSON-RPC v2 over stdio, with a small default surface optimized for real scripts and apps. + +The generated wire-model layer is currently sourced from the bundled v2 schema and exposed as Pydantic models with snake_case Python fields that serialize back to the app-server’s camelCase wire format. + +## Install + +```bash +cd sdk/python +python -m pip install -e . +``` + +Published SDK builds pin an exact `codex-cli-bin` runtime dependency. For local +repo development, pass `AppServerConfig(codex_bin=...)` to point at a local +build explicitly. + +## Quickstart + +```python +from codex_app_server import Codex, TextInput + +with Codex() as codex: + thread = codex.thread_start(model="gpt-5") + result = thread.turn(TextInput("Say hello in one sentence.")).run() + print(result.text) +``` + +## Docs map + +- Golden path tutorial: `docs/getting-started.md` +- API reference (signatures + behavior): `docs/api-reference.md` +- Common decisions and pitfalls: `docs/faq.md` +- Runnable examples index: `examples/README.md` +- Jupyter walkthrough notebook: `notebooks/sdk_walkthrough.ipynb` + +## Examples + +Start here: + +```bash +cd sdk/python +python examples/01_quickstart_constructor/sync.py +python examples/01_quickstart_constructor/async.py +``` + +## Runtime packaging + +The repo no longer checks `codex` binaries into `sdk/python`. + +Published SDK builds are pinned to an exact `codex-cli-bin` package version, +and that runtime package carries the platform-specific binary for the target +wheel. + +For local repo development, the checked-in `sdk/python-runtime` package is only +a template for staged release artifacts. Editable installs should use an +explicit `codex_bin` override instead. + +## Maintainer workflow + +```bash +cd sdk/python +python scripts/update_sdk_artifacts.py generate-types +python scripts/update_sdk_artifacts.py \ + stage-sdk \ + /tmp/codex-python-release/codex-app-server-sdk \ + --runtime-version 1.2.3 +python scripts/update_sdk_artifacts.py \ + stage-runtime \ + /tmp/codex-python-release/codex-cli-bin \ + /path/to/codex \ + --runtime-version 1.2.3 +``` + +This supports the CI release flow: + +- run `generate-types` before packaging +- stage `codex-app-server-sdk` once with an exact `codex-cli-bin==...` dependency +- stage `codex-cli-bin` on each supported platform runner with the same pinned runtime version +- build and publish `codex-cli-bin` as platform wheels only; do not publish an sdist + +## Compatibility and versioning + +- Package: `codex-app-server-sdk` +- Runtime package: `codex-cli-bin` +- Current SDK version in this repo: `0.2.0` +- Python: `>=3.10` +- Target protocol: Codex `app-server` JSON-RPC v2 +- Recommendation: keep SDK and `codex` CLI reasonably up to date together + +## Notes + +- `Codex()` is eager and performs startup + `initialize` in the constructor. +- Use context managers (`with Codex() as codex:`) to ensure shutdown. +- For transient overload, use `codex_app_server.retry.retry_on_overload`. diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md new file mode 100644 index 0000000000..ebfd2ddad2 --- /dev/null +++ b/sdk/python/docs/faq.md @@ -0,0 +1,77 @@ +# FAQ + +## Thread vs turn + +- A `Thread` is conversation state. +- A `Turn` is one model execution inside that thread. +- Multi-turn chat means multiple turns on the same `Thread`. + +## `run()` vs `stream()` + +- `Turn.run()` is the easiest path. It consumes events until completion and returns `TurnResult`. +- `Turn.stream()` yields raw notifications (`Notification`) so you can react event-by-event. + +Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout logic, or custom parsing. + +## Sync vs async clients + +- `Codex` is the minimal sync SDK and best default. +- `AsyncAppServerClient` wraps the sync transport with `asyncio.to_thread(...)` for async-friendly call sites. + +If your app is not already async, stay with `Codex`. + +## `thread(...)` vs `thread_resume(...)` + +- `codex.thread(thread_id)` only binds a local helper to an existing thread ID. +- `codex.thread_resume(thread_id, ...)` performs a `thread/resume` RPC and can apply overrides (model, instructions, sandbox, etc.). + +Use `thread(...)` for simple continuation. Use `thread_resume(...)` when you need explicit resume semantics or override fields. + +## Why does constructor fail? + +`Codex()` is eager: it starts transport and calls `initialize` in `__init__`. + +Common causes: + +- published runtime package (`codex-cli-bin`) is not installed +- local `codex_bin` override points to a missing file +- local auth/session is missing +- incompatible/old app-server + +Maintainers stage releases by building the SDK once and the runtime once per +platform with the same pinned runtime version. Publish `codex-cli-bin` as +platform wheels only; do not publish an sdist: + +```bash +cd sdk/python +python scripts/update_sdk_artifacts.py generate-types +python scripts/update_sdk_artifacts.py \ + stage-sdk \ + /tmp/codex-python-release/codex-app-server-sdk \ + --runtime-version 1.2.3 +python scripts/update_sdk_artifacts.py \ + stage-runtime \ + /tmp/codex-python-release/codex-cli-bin \ + /path/to/codex \ + --runtime-version 1.2.3 +``` + +## Why does a turn "hang"? + +A turn is complete only when `turn/completed` arrives for that turn ID. + +- `run()` waits for this automatically. +- With `stream()`, make sure you keep consuming notifications until completion. + +## How do I retry safely? + +Use `retry_on_overload(...)` for transient overload failures (`ServerBusyError`). + +Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundError`, fix inputs/version compatibility instead. + +## Common pitfalls + +- Starting a new thread for every prompt when you wanted continuity. +- Forgetting to `close()` (or not using `with Codex() as codex:`). +- Ignoring `TurnResult.status` and `TurnResult.error`. +- Mixing SDK input classes with raw dicts incorrectly in minimal API paths. diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md new file mode 100644 index 0000000000..9108902b38 --- /dev/null +++ b/sdk/python/docs/getting-started.md @@ -0,0 +1,75 @@ +# Getting Started + +This is the fastest path from install to a multi-turn thread using the minimal SDK surface. + +## 1) Install + +From repo root: + +```bash +cd sdk/python +python -m pip install -e . +``` + +Requirements: + +- Python `>=3.10` +- installed `codex-cli-bin` runtime package, or an explicit `codex_bin` override +- Local Codex auth/session configured + +## 2) Run your first turn + +```python +from codex_app_server import Codex, TextInput + +with Codex() as codex: + print("Server:", codex.metadata.server_name, codex.metadata.server_version) + + thread = codex.thread_start(model="gpt-5") + result = thread.turn(TextInput("Say hello in one sentence.")).run() + + print("Thread:", result.thread_id) + print("Turn:", result.turn_id) + print("Status:", result.status) + print("Text:", result.text) +``` + +What happened: + +- `Codex()` started and initialized `codex app-server`. +- `thread_start(...)` created a thread. +- `turn(...).run()` consumed events until `turn/completed` and returned a `TurnResult`. + +## 3) Continue the same thread (multi-turn) + +```python +from codex_app_server import Codex, TextInput + +with Codex() as codex: + thread = codex.thread_start(model="gpt-5") + + first = thread.turn(TextInput("Summarize Rust ownership in 2 bullets.")).run() + second = thread.turn(TextInput("Now explain it to a Python developer.")).run() + + print("first:", first.text) + print("second:", second.text) +``` + +## 4) Resume an existing thread + +```python +from codex_app_server import Codex, TextInput + +THREAD_ID = "thr_123" # replace with a real id + +with Codex() as codex: + thread = codex.thread(THREAD_ID) + result = thread.turn(TextInput("Continue where we left off.")).run() + print(result.text) +``` + +## 5) Next stops + +- API surface and signatures: `docs/api-reference.md` +- Common decisions/pitfalls: `docs/faq.md` +- End-to-end runnable examples: `examples/README.md` diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 0000000000..f5129cbf93 --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "codex-app-server-sdk" +version = "0.2.0" +description = "Python SDK for Codex app-server v2" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "Apache-2.0" } +authors = [{ name = "OpenClaw Assistant" }] +keywords = ["codex", "json-rpc", "sdk", "llm", "app-server"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = ["pydantic>=2.12"] + +[project.urls] +Homepage = "https://github.com/openai/codex" +Repository = "https://github.com/openai/codex" +Issues = "https://github.com/openai/codex/issues" + +[project.optional-dependencies] +dev = ["pytest>=8.0", "datamodel-code-generator==0.31.2", "ruff>=0.11"] + +[tool.hatch.build] +exclude = [ + ".venv/**", + ".venv2/**", + ".pytest_cache/**", + "dist/**", + "build/**", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/codex_app_server"] +include = [ + "src/codex_app_server/py.typed", +] + +[tool.hatch.build.targets.sdist] +include = [ + "src/codex_app_server/**", + "README.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "RELEASE_CHECKLIST.md", + "pyproject.toml", +] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py new file mode 100755 index 0000000000..da4cbceb1a --- /dev/null +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -0,0 +1,998 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import importlib +import json +import platform +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import types +import typing +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Sequence, get_args, get_origin + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def sdk_root() -> Path: + return repo_root() / "sdk" / "python" + + +def python_runtime_root() -> Path: + return repo_root() / "sdk" / "python-runtime" + + +def schema_bundle_path() -> Path: + return ( + repo_root() + / "codex-rs" + / "app-server-protocol" + / "schema" + / "json" + / "codex_app_server_protocol.v2.schemas.json" + ) + + +def schema_root_dir() -> Path: + return repo_root() / "codex-rs" / "app-server-protocol" / "schema" / "json" + + +def _is_windows() -> bool: + return platform.system().lower().startswith("win") + + +def runtime_binary_name() -> str: + return "codex.exe" if _is_windows() else "codex" + + +def staged_runtime_bin_path(root: Path) -> Path: + return root / "src" / "codex_cli_bin" / "bin" / runtime_binary_name() + + +def run(cmd: list[str], cwd: Path) -> None: + subprocess.run(cmd, cwd=str(cwd), check=True) + + +def run_python_module(module: str, args: list[str], cwd: Path) -> None: + run([sys.executable, "-m", module, *args], cwd) + + +def current_sdk_version() -> str: + match = re.search( + r'^version = "([^"]+)"$', + (sdk_root() / "pyproject.toml").read_text(), + flags=re.MULTILINE, + ) + if match is None: + raise RuntimeError("Could not determine Python SDK version from pyproject.toml") + return match.group(1) + + +def _copy_package_tree(src: Path, dst: Path) -> None: + if dst.exists(): + if dst.is_dir(): + shutil.rmtree(dst) + else: + dst.unlink() + shutil.copytree( + src, + dst, + ignore=shutil.ignore_patterns( + ".venv", + ".venv2", + ".pytest_cache", + "__pycache__", + "build", + "dist", + "*.pyc", + ), + ) + + +def _rewrite_project_version(pyproject_text: str, version: str) -> str: + updated, count = re.subn( + r'^version = "[^"]+"$', + f'version = "{version}"', + pyproject_text, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise RuntimeError("Could not rewrite project version in pyproject.toml") + return updated + + +def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) -> str: + match = re.search(r"^dependencies = \[(.*?)\]$", pyproject_text, flags=re.MULTILINE) + if match is None: + raise RuntimeError( + "Could not find dependencies array in sdk/python/pyproject.toml" + ) + + raw_items = [item.strip() for item in match.group(1).split(",") if item.strip()] + raw_items = [item for item in raw_items if "codex-cli-bin" not in item] + raw_items.append(f'"codex-cli-bin=={runtime_version}"') + replacement = "dependencies = [\n " + ",\n ".join(raw_items) + ",\n]" + return pyproject_text[: match.start()] + replacement + pyproject_text[match.end() :] + + +def stage_python_sdk_package( + staging_dir: Path, sdk_version: str, runtime_version: str +) -> Path: + _copy_package_tree(sdk_root(), staging_dir) + sdk_bin_dir = staging_dir / "src" / "codex_app_server" / "bin" + if sdk_bin_dir.exists(): + shutil.rmtree(sdk_bin_dir) + + pyproject_path = staging_dir / "pyproject.toml" + pyproject_text = pyproject_path.read_text() + pyproject_text = _rewrite_project_version(pyproject_text, sdk_version) + pyproject_text = _rewrite_sdk_runtime_dependency(pyproject_text, runtime_version) + pyproject_path.write_text(pyproject_text) + return staging_dir + + +def stage_python_runtime_package( + staging_dir: Path, runtime_version: str, binary_path: Path +) -> Path: + _copy_package_tree(python_runtime_root(), staging_dir) + + pyproject_path = staging_dir / "pyproject.toml" + pyproject_path.write_text( + _rewrite_project_version(pyproject_path.read_text(), runtime_version) + ) + + out_bin = staged_runtime_bin_path(staging_dir) + out_bin.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(binary_path, out_bin) + if not _is_windows(): + out_bin.chmod( + out_bin.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + return staging_dir + + +def _flatten_string_enum_one_of(definition: dict[str, Any]) -> bool: + branches = definition.get("oneOf") + if not isinstance(branches, list) or not branches: + return False + + enum_values: list[str] = [] + for branch in branches: + if not isinstance(branch, dict): + return False + if branch.get("type") != "string": + return False + + enum = branch.get("enum") + if not isinstance(enum, list) or len(enum) != 1 or not isinstance(enum[0], str): + return False + + extra_keys = set(branch) - {"type", "enum", "description", "title"} + if extra_keys: + return False + + enum_values.append(enum[0]) + + description = definition.get("description") + title = definition.get("title") + definition.clear() + definition["type"] = "string" + definition["enum"] = enum_values + if isinstance(description, str): + definition["description"] = description + if isinstance(title, str): + definition["title"] = title + return True + + +DISCRIMINATOR_KEYS = ("type", "method", "mode", "state", "status", "role", "reason") + + +def _to_pascal_case(value: str) -> str: + parts = re.split(r"[^0-9A-Za-z]+", value) + compact = "".join(part[:1].upper() + part[1:] for part in parts if part) + return compact or "Value" + + +def _string_literal(value: Any) -> str | None: + if not isinstance(value, dict): + return None + const = value.get("const") + if isinstance(const, str): + return const + + enum = value.get("enum") + if isinstance(enum, list) and enum and len(enum) == 1 and isinstance(enum[0], str): + return enum[0] + return None + + +def _enum_literals(value: Any) -> list[str] | None: + if not isinstance(value, dict): + return None + enum = value.get("enum") + if ( + not isinstance(enum, list) + or not enum + or not all(isinstance(item, str) for item in enum) + ): + return None + return list(enum) + + +def _literal_from_property(props: dict[str, Any], key: str) -> str | None: + return _string_literal(props.get(key)) + + +def _variant_definition_name(base: str, variant: dict[str, Any]) -> str | None: + # datamodel-code-generator invents numbered helper names for inline union + # branches unless they carry a stable, unique title up front. We derive + # those titles from the branch discriminator or other identifying shape. + props = variant.get("properties") + if isinstance(props, dict): + for key in DISCRIMINATOR_KEYS: + literal = _literal_from_property(props, key) + if literal is None: + continue + pascal = _to_pascal_case(literal) + if base == "ClientRequest": + return f"{pascal}Request" + if base == "ServerRequest": + return f"{pascal}ServerRequest" + if base == "ClientNotification": + return f"{pascal}ClientNotification" + if base == "ServerNotification": + return f"{pascal}ServerNotification" + if base == "EventMsg": + return f"{pascal}EventMsg" + return f"{pascal}{base}" + + if len(props) == 1: + key = next(iter(props)) + pascal = _string_literal(props[key]) + return f"{_to_pascal_case(pascal or key)}{base}" + + required = variant.get("required") + if ( + isinstance(required, list) + and len(required) == 1 + and isinstance(required[0], str) + ): + return f"{_to_pascal_case(required[0])}{base}" + + enum_literals = _enum_literals(variant) + if enum_literals is not None: + if len(enum_literals) == 1: + return f"{_to_pascal_case(enum_literals[0])}{base}" + return f"{base}Value" + + return None + + +def _variant_collision_key( + base: str, variant: dict[str, Any], generated_name: str +) -> str: + parts = [f"base={base}", f"generated={generated_name}"] + props = variant.get("properties") + if isinstance(props, dict): + for key in DISCRIMINATOR_KEYS: + literal = _literal_from_property(props, key) + if literal is not None: + parts.append(f"{key}={literal}") + if len(props) == 1: + parts.append(f"only_property={next(iter(props))}") + + required = variant.get("required") + if ( + isinstance(required, list) + and len(required) == 1 + and isinstance(required[0], str) + ): + parts.append(f"required_only={required[0]}") + + enum_literals = _enum_literals(variant) + if enum_literals is not None: + parts.append(f"enum={'|'.join(enum_literals)}") + + return "|".join(parts) + + +def _set_discriminator_titles(props: dict[str, Any], owner: str) -> None: + for key in DISCRIMINATOR_KEYS: + prop = props.get(key) + if not isinstance(prop, dict): + continue + if _string_literal(prop) is None or "title" in prop: + continue + prop["title"] = f"{owner}{_to_pascal_case(key)}" + + +def _annotate_variant_list(variants: list[Any], base: str | None) -> None: + seen = { + variant["title"] + for variant in variants + if isinstance(variant, dict) and isinstance(variant.get("title"), str) + } + + for variant in variants: + if not isinstance(variant, dict): + continue + + variant_name = variant.get("title") + generated_name = _variant_definition_name(base, variant) if base else None + if generated_name is not None and ( + not isinstance(variant_name, str) + or "/" in variant_name + or variant_name != generated_name + ): + # Titles like `Thread/startedNotification` sanitize poorly in + # Python, and envelope titles like `ErrorNotification` collide + # with their payload model names. Rewrite them before codegen so + # we get `ThreadStartedServerNotification` instead of `...1`. + if generated_name in seen and variant_name != generated_name: + raise RuntimeError( + "Variant title naming collision detected: " + f"{_variant_collision_key(base or '', variant, generated_name)}" + ) + variant["title"] = generated_name + seen.add(generated_name) + variant_name = generated_name + + if isinstance(variant_name, str): + props = variant.get("properties") + if isinstance(props, dict): + _set_discriminator_titles(props, variant_name) + + _annotate_schema(variant, base) + + +def _annotate_schema(value: Any, base: str | None = None) -> None: + if isinstance(value, list): + for item in value: + _annotate_schema(item, base) + return + + if not isinstance(value, dict): + return + + owner = value.get("title") + props = value.get("properties") + if isinstance(owner, str) and isinstance(props, dict): + _set_discriminator_titles(props, owner) + + one_of = value.get("oneOf") + if isinstance(one_of, list): + # Walk nested unions recursively so every inline branch gets the same + # title normalization treatment before we hand the bundle to Python + # codegen. + _annotate_variant_list(one_of, base) + + any_of = value.get("anyOf") + if isinstance(any_of, list): + _annotate_variant_list(any_of, base) + + definitions = value.get("definitions") + if isinstance(definitions, dict): + for name, schema in definitions.items(): + _annotate_schema(schema, name if isinstance(name, str) else base) + + defs = value.get("$defs") + if isinstance(defs, dict): + for name, schema in defs.items(): + _annotate_schema(schema, name if isinstance(name, str) else base) + + for key, child in value.items(): + if key in {"oneOf", "anyOf", "definitions", "$defs"}: + continue + _annotate_schema(child, base) + + +def _normalized_schema_bundle_text() -> str: + schema = json.loads(schema_bundle_path().read_text()) + definitions = schema.get("definitions", {}) + if isinstance(definitions, dict): + for definition in definitions.values(): + if isinstance(definition, dict): + _flatten_string_enum_one_of(definition) + # Normalize the schema into something datamodel-code-generator can map to + # stable class names instead of anonymous numbered helpers. + _annotate_schema(schema) + return json.dumps(schema, indent=2, sort_keys=True) + "\n" + + +def generate_v2_all() -> None: + out_path = sdk_root() / "src" / "codex_app_server" / "generated" / "v2_all.py" + out_dir = out_path.parent + old_package_dir = out_dir / "v2_all" + if old_package_dir.exists(): + shutil.rmtree(old_package_dir) + out_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as td: + normalized_bundle = Path(td) / schema_bundle_path().name + normalized_bundle.write_text(_normalized_schema_bundle_text()) + run_python_module( + "datamodel_code_generator", + [ + "--input", + str(normalized_bundle), + "--input-file-type", + "jsonschema", + "--output", + str(out_path), + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-python-version", + "3.11", + "--use-standard-collections", + "--enum-field-as-literal", + "one", + "--field-constraints", + "--use-default-kwarg", + "--snake-case-field", + "--allow-population-by-field-name", + # Once the schema prepass has assigned stable titles, tell the + # generator to prefer those titles as the emitted class names. + "--use-title-as-name", + "--use-annotated", + "--use-union-operator", + "--disable-timestamp", + # Keep the generated file formatted deterministically so the + # checked-in artifact only changes when the schema does. + "--formatters", + "ruff-format", + ], + cwd=sdk_root(), + ) + _normalize_generated_timestamps(out_path) + + +def _notification_specs() -> list[tuple[str, str]]: + server_notifications = json.loads( + (schema_root_dir() / "ServerNotification.json").read_text() + ) + one_of = server_notifications.get("oneOf", []) + generated_source = ( + sdk_root() / "src" / "codex_app_server" / "generated" / "v2_all.py" + ).read_text() + + specs: list[tuple[str, str]] = [] + + for variant in one_of: + props = variant.get("properties", {}) + method_meta = props.get("method", {}) + params_meta = props.get("params", {}) + + methods = method_meta.get("enum", []) + if len(methods) != 1: + continue + method = methods[0] + if not isinstance(method, str): + continue + + ref = params_meta.get("$ref") + if not isinstance(ref, str) or not ref.startswith("#/definitions/"): + continue + class_name = ref.split("/")[-1] + if ( + f"class {class_name}(" not in generated_source + and f"{class_name} =" not in generated_source + ): + # Skip schema variants that are not emitted into the generated v2 surface. + continue + specs.append((method, class_name)) + + specs.sort() + return specs + + +def generate_notification_registry() -> None: + out = ( + sdk_root() + / "src" + / "codex_app_server" + / "generated" + / "notification_registry.py" + ) + specs = _notification_specs() + class_names = sorted({class_name for _, class_name in specs}) + + lines = [ + "# Auto-generated by scripts/update_sdk_artifacts.py", + "# DO NOT EDIT MANUALLY.", + "", + "from __future__ import annotations", + "", + "from pydantic import BaseModel", + "", + ] + + for class_name in class_names: + lines.append(f"from .v2_all import {class_name}") + lines.extend( + [ + "", + "NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {", + ] + ) + for method, class_name in specs: + lines.append(f' "{method}": {class_name},') + lines.extend(["}", ""]) + + out.write_text("\n".join(lines)) + + +def _normalize_generated_timestamps(root: Path) -> None: + timestamp_re = re.compile(r"^#\s+timestamp:\s+.+$", flags=re.MULTILINE) + py_files = [root] if root.is_file() else sorted(root.rglob("*.py")) + for py_file in py_files: + content = py_file.read_text() + normalized = timestamp_re.sub("# timestamp: ", content) + if normalized != content: + py_file.write_text(normalized) + + +FIELD_ANNOTATION_OVERRIDES: dict[str, str] = { + # Keep public API typed without falling back to `Any`. + "config": "JsonObject", + "output_schema": "JsonObject", +} + + +@dataclass(slots=True) +class PublicFieldSpec: + wire_name: str + py_name: str + annotation: str + required: bool + + +@dataclass(frozen=True) +class CliOps: + generate_types: Callable[[], None] + stage_python_sdk_package: Callable[[Path, str, str], Path] + stage_python_runtime_package: Callable[[Path, str, Path], Path] + current_sdk_version: Callable[[], str] + + +def _annotation_to_source(annotation: Any) -> str: + origin = get_origin(annotation) + if origin is typing.Annotated: + return _annotation_to_source(get_args(annotation)[0]) + if origin in (typing.Union, types.UnionType): + parts: list[str] = [] + for arg in get_args(annotation): + rendered = _annotation_to_source(arg) + if rendered not in parts: + parts.append(rendered) + return " | ".join(parts) + if origin is list: + args = get_args(annotation) + item = _annotation_to_source(args[0]) if args else "Any" + return f"list[{item}]" + if origin is dict: + args = get_args(annotation) + key = _annotation_to_source(args[0]) if args else "str" + val = _annotation_to_source(args[1]) if len(args) > 1 else "Any" + return f"dict[{key}, {val}]" + if annotation is Any or annotation is typing.Any: + return "Any" + if annotation is None or annotation is type(None): + return "None" + if isinstance(annotation, type): + if annotation.__module__ == "builtins": + return annotation.__name__ + return annotation.__name__ + return repr(annotation) + + +def _camel_to_snake(name: str) -> str: + head = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name) + return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", head).lower() + + +def _load_public_fields( + module_name: str, class_name: str, *, exclude: set[str] | None = None +) -> list[PublicFieldSpec]: + exclude = exclude or set() + module = importlib.import_module(module_name) + model = getattr(module, class_name) + fields: list[PublicFieldSpec] = [] + for name, field in model.model_fields.items(): + if name in exclude: + continue + required = field.is_required() + annotation = _annotation_to_source(field.annotation) + override = FIELD_ANNOTATION_OVERRIDES.get(name) + if override is not None: + annotation = override if required else f"{override} | None" + fields.append( + PublicFieldSpec( + wire_name=name, + py_name=name, + annotation=annotation, + required=required, + ) + ) + return fields + + +def _kw_signature_lines(fields: list[PublicFieldSpec]) -> list[str]: + lines: list[str] = [] + for field in fields: + default = "" if field.required else " = None" + lines.append(f" {field.py_name}: {field.annotation}{default},") + return lines + + +def _model_arg_lines( + fields: list[PublicFieldSpec], *, indent: str = " " +) -> list[str]: + return [f"{indent}{field.wire_name}={field.py_name}," for field in fields] + + +def _replace_generated_block(source: str, block_name: str, body: str) -> str: + start_tag = f" # BEGIN GENERATED: {block_name}" + end_tag = f" # END GENERATED: {block_name}" + pattern = re.compile(rf"(?s){re.escape(start_tag)}\n.*?\n{re.escape(end_tag)}") + replacement = f"{start_tag}\n{body.rstrip()}\n{end_tag}" + updated, count = pattern.subn(replacement, source, count=1) + if count != 1: + raise RuntimeError(f"Could not update generated block: {block_name}") + return updated + + +def _render_codex_block( + thread_start_fields: list[PublicFieldSpec], + thread_list_fields: list[PublicFieldSpec], + resume_fields: list[PublicFieldSpec], + fork_fields: list[PublicFieldSpec], +) -> str: + lines = [ + " def thread_start(", + " self,", + " *,", + *_kw_signature_lines(thread_start_fields), + " ) -> Thread:", + " params = ThreadStartParams(", + *_model_arg_lines(thread_start_fields), + " )", + " started = self._client.thread_start(params)", + " return Thread(self._client, started.thread.id)", + "", + " def thread_list(", + " self,", + " *,", + *_kw_signature_lines(thread_list_fields), + " ) -> ThreadListResponse:", + " params = ThreadListParams(", + *_model_arg_lines(thread_list_fields), + " )", + " return self._client.thread_list(params)", + "", + " def thread_resume(", + " self,", + " thread_id: str,", + " *,", + *_kw_signature_lines(resume_fields), + " ) -> Thread:", + " params = ThreadResumeParams(", + " thread_id=thread_id,", + *_model_arg_lines(resume_fields), + " )", + " resumed = self._client.thread_resume(thread_id, params)", + " return Thread(self._client, resumed.thread.id)", + "", + " def thread_fork(", + " self,", + " thread_id: str,", + " *,", + *_kw_signature_lines(fork_fields), + " ) -> Thread:", + " params = ThreadForkParams(", + " thread_id=thread_id,", + *_model_arg_lines(fork_fields), + " )", + " forked = self._client.thread_fork(thread_id, params)", + " return Thread(self._client, forked.thread.id)", + "", + " def thread_archive(self, thread_id: str) -> ThreadArchiveResponse:", + " return self._client.thread_archive(thread_id)", + "", + " def thread_unarchive(self, thread_id: str) -> Thread:", + " unarchived = self._client.thread_unarchive(thread_id)", + " return Thread(self._client, unarchived.thread.id)", + ] + return "\n".join(lines) + + +def _render_async_codex_block( + thread_start_fields: list[PublicFieldSpec], + thread_list_fields: list[PublicFieldSpec], + resume_fields: list[PublicFieldSpec], + fork_fields: list[PublicFieldSpec], +) -> str: + lines = [ + " async def thread_start(", + " self,", + " *,", + *_kw_signature_lines(thread_start_fields), + " ) -> AsyncThread:", + " await self._ensure_initialized()", + " params = ThreadStartParams(", + *_model_arg_lines(thread_start_fields), + " )", + " started = await self._client.thread_start(params)", + " return AsyncThread(self, started.thread.id)", + "", + " async def thread_list(", + " self,", + " *,", + *_kw_signature_lines(thread_list_fields), + " ) -> ThreadListResponse:", + " await self._ensure_initialized()", + " params = ThreadListParams(", + *_model_arg_lines(thread_list_fields), + " )", + " return await self._client.thread_list(params)", + "", + " async def thread_resume(", + " self,", + " thread_id: str,", + " *,", + *_kw_signature_lines(resume_fields), + " ) -> AsyncThread:", + " await self._ensure_initialized()", + " params = ThreadResumeParams(", + " thread_id=thread_id,", + *_model_arg_lines(resume_fields), + " )", + " resumed = await self._client.thread_resume(thread_id, params)", + " return AsyncThread(self, resumed.thread.id)", + "", + " async def thread_fork(", + " self,", + " thread_id: str,", + " *,", + *_kw_signature_lines(fork_fields), + " ) -> AsyncThread:", + " await self._ensure_initialized()", + " params = ThreadForkParams(", + " thread_id=thread_id,", + *_model_arg_lines(fork_fields), + " )", + " forked = await self._client.thread_fork(thread_id, params)", + " return AsyncThread(self, forked.thread.id)", + "", + " async def thread_archive(self, thread_id: str) -> ThreadArchiveResponse:", + " await self._ensure_initialized()", + " return await self._client.thread_archive(thread_id)", + "", + " async def thread_unarchive(self, thread_id: str) -> AsyncThread:", + " await self._ensure_initialized()", + " unarchived = await self._client.thread_unarchive(thread_id)", + " return AsyncThread(self, unarchived.thread.id)", + ] + return "\n".join(lines) + + +def _render_thread_block( + turn_fields: list[PublicFieldSpec], +) -> str: + lines = [ + " def turn(", + " self,", + " input: Input,", + " *,", + *_kw_signature_lines(turn_fields), + " ) -> Turn:", + " wire_input = _to_wire_input(input)", + " params = TurnStartParams(", + " thread_id=self.id,", + " input=wire_input,", + *_model_arg_lines(turn_fields), + " )", + " turn = self._client.turn_start(self.id, wire_input, params=params)", + " return Turn(self._client, self.id, turn.turn.id)", + ] + return "\n".join(lines) + + +def _render_async_thread_block( + turn_fields: list[PublicFieldSpec], +) -> str: + lines = [ + " async def turn(", + " self,", + " input: Input,", + " *,", + *_kw_signature_lines(turn_fields), + " ) -> AsyncTurn:", + " await self._codex._ensure_initialized()", + " wire_input = _to_wire_input(input)", + " params = TurnStartParams(", + " thread_id=self.id,", + " input=wire_input,", + *_model_arg_lines(turn_fields), + " )", + " turn = await self._codex._client.turn_start(", + " self.id,", + " wire_input,", + " params=params,", + " )", + " return AsyncTurn(self._codex, self.id, turn.turn.id)", + ] + return "\n".join(lines) + + +def generate_public_api_flat_methods() -> None: + src_dir = sdk_root() / "src" + public_api_path = src_dir / "codex_app_server" / "public_api.py" + if not public_api_path.exists(): + # PR2 can run codegen before the ergonomic public API layer is added. + return + src_dir_str = str(src_dir) + if src_dir_str not in sys.path: + sys.path.insert(0, src_dir_str) + + thread_start_fields = _load_public_fields( + "codex_app_server.generated.v2_all", + "ThreadStartParams", + ) + thread_list_fields = _load_public_fields( + "codex_app_server.generated.v2_all", + "ThreadListParams", + ) + thread_resume_fields = _load_public_fields( + "codex_app_server.generated.v2_all", + "ThreadResumeParams", + exclude={"thread_id"}, + ) + thread_fork_fields = _load_public_fields( + "codex_app_server.generated.v2_all", + "ThreadForkParams", + exclude={"thread_id"}, + ) + turn_start_fields = _load_public_fields( + "codex_app_server.generated.v2_all", + "TurnStartParams", + exclude={"thread_id", "input"}, + ) + + source = public_api_path.read_text() + source = _replace_generated_block( + source, + "Codex.flat_methods", + _render_codex_block( + thread_start_fields, + thread_list_fields, + thread_resume_fields, + thread_fork_fields, + ), + ) + source = _replace_generated_block( + source, + "AsyncCodex.flat_methods", + _render_async_codex_block( + thread_start_fields, + thread_list_fields, + thread_resume_fields, + thread_fork_fields, + ), + ) + source = _replace_generated_block( + source, + "Thread.flat_methods", + _render_thread_block(turn_start_fields), + ) + source = _replace_generated_block( + source, + "AsyncThread.flat_methods", + _render_async_thread_block(turn_start_fields), + ) + public_api_path.write_text(source) + + +def generate_types() -> None: + # v2_all is the authoritative generated surface. + generate_v2_all() + generate_notification_registry() + generate_public_api_flat_methods() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Single SDK maintenance entrypoint") + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser( + "generate-types", help="Regenerate Python protocol-derived types" + ) + + stage_sdk_parser = subparsers.add_parser( + "stage-sdk", + help="Stage a releasable SDK package pinned to a runtime version", + ) + stage_sdk_parser.add_argument( + "staging_dir", + type=Path, + help="Output directory for the staged SDK package", + ) + stage_sdk_parser.add_argument( + "--runtime-version", + required=True, + help="Pinned codex-cli-bin version for the staged SDK package", + ) + stage_sdk_parser.add_argument( + "--sdk-version", + help="Version to write into the staged SDK package (defaults to sdk/python current version)", + ) + + stage_runtime_parser = subparsers.add_parser( + "stage-runtime", + help="Stage a releasable runtime package for the current platform", + ) + stage_runtime_parser.add_argument( + "staging_dir", + type=Path, + help="Output directory for the staged runtime package", + ) + stage_runtime_parser.add_argument( + "runtime_binary", + type=Path, + help="Path to the codex binary to package for this platform", + ) + stage_runtime_parser.add_argument( + "--runtime-version", + required=True, + help="Version to write into the staged runtime package", + ) + return parser + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + return build_parser().parse_args(list(argv) if argv is not None else None) + + +def default_cli_ops() -> CliOps: + return CliOps( + generate_types=generate_types, + stage_python_sdk_package=stage_python_sdk_package, + stage_python_runtime_package=stage_python_runtime_package, + current_sdk_version=current_sdk_version, + ) + + +def run_command(args: argparse.Namespace, ops: CliOps) -> None: + if args.command == "generate-types": + ops.generate_types() + elif args.command == "stage-sdk": + ops.generate_types() + ops.stage_python_sdk_package( + args.staging_dir, + args.sdk_version or ops.current_sdk_version(), + args.runtime_version, + ) + elif args.command == "stage-runtime": + ops.stage_python_runtime_package( + args.staging_dir, + args.runtime_version, + args.runtime_binary.resolve(), + ) + + +def main(argv: Sequence[str] | None = None, ops: CliOps | None = None) -> None: + args = parse_args(argv) + run_command(args, ops or default_cli_ops()) + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/src/codex_app_server/__init__.py b/sdk/python/src/codex_app_server/__init__.py new file mode 100644 index 0000000000..aff63176b9 --- /dev/null +++ b/sdk/python/src/codex_app_server/__init__.py @@ -0,0 +1,10 @@ +from .client import AppServerClient, AppServerConfig +from .errors import AppServerError, JsonRpcError, TransportClosedError + +__all__ = [ + "AppServerClient", + "AppServerConfig", + "AppServerError", + "JsonRpcError", + "TransportClosedError", +] diff --git a/sdk/python/src/codex_app_server/client.py b/sdk/python/src/codex_app_server/client.py new file mode 100644 index 0000000000..aa7b574a3f --- /dev/null +++ b/sdk/python/src/codex_app_server/client.py @@ -0,0 +1,540 @@ +from __future__ import annotations + +import json +import os +import subprocess +import threading +import uuid +from collections import deque +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable, Iterator, TypeVar + +from pydantic import BaseModel + +from .errors import AppServerError, TransportClosedError, map_jsonrpc_error +from .generated.notification_registry import NOTIFICATION_MODELS +from .generated.v2_all import ( + AgentMessageDeltaNotification, + ModelListResponse, + ThreadArchiveResponse, + ThreadCompactStartResponse, + ThreadForkParams as V2ThreadForkParams, + ThreadForkResponse, + ThreadListParams as V2ThreadListParams, + ThreadListResponse, + ThreadReadResponse, + ThreadResumeParams as V2ThreadResumeParams, + ThreadResumeResponse, + ThreadSetNameResponse, + ThreadStartParams as V2ThreadStartParams, + ThreadStartResponse, + ThreadUnarchiveResponse, + TurnCompletedNotification, + TurnInterruptResponse, + TurnStartParams as V2TurnStartParams, + TurnStartResponse, + TurnSteerResponse, +) +from .models import ( + InitializeResponse, + JsonObject, + JsonValue, + Notification, + UnknownNotification, +) +from .retry import retry_on_overload + +ModelT = TypeVar("ModelT", bound=BaseModel) +ApprovalHandler = Callable[[str, JsonObject | None], JsonObject] +RUNTIME_PKG_NAME = "codex-cli-bin" + + +def _params_dict( + params: ( + V2ThreadStartParams + | V2ThreadResumeParams + | V2ThreadListParams + | V2ThreadForkParams + | V2TurnStartParams + | JsonObject + | None + ), +) -> JsonObject: + if params is None: + return {} + if hasattr(params, "model_dump"): + dumped = params.model_dump( + by_alias=True, + exclude_none=True, + mode="json", + ) + if not isinstance(dumped, dict): + raise TypeError("Expected model_dump() to return dict") + return dumped + if isinstance(params, dict): + return params + raise TypeError(f"Expected generated params model or dict, got {type(params).__name__}") + + +def _installed_codex_path() -> Path: + try: + from codex_cli_bin import bundled_codex_path + except ImportError as exc: + raise FileNotFoundError( + "Unable to locate the pinned Codex runtime. Install the published SDK build " + f"with its {RUNTIME_PKG_NAME} dependency, or set AppServerConfig.codex_bin " + "explicitly." + ) from exc + + return bundled_codex_path() + + +@dataclass(frozen=True) +class CodexBinResolverOps: + installed_codex_path: Callable[[], Path] + path_exists: Callable[[Path], bool] + + +def _default_codex_bin_resolver_ops() -> CodexBinResolverOps: + return CodexBinResolverOps( + installed_codex_path=_installed_codex_path, + path_exists=lambda path: path.exists(), + ) + + +def resolve_codex_bin(config: "AppServerConfig", ops: CodexBinResolverOps) -> Path: + if config.codex_bin is not None: + codex_bin = Path(config.codex_bin) + if not ops.path_exists(codex_bin): + raise FileNotFoundError( + f"Codex binary not found at {codex_bin}. Set AppServerConfig.codex_bin " + "to a valid binary path." + ) + return codex_bin + + return ops.installed_codex_path() + + +def _resolve_codex_bin(config: "AppServerConfig") -> Path: + return resolve_codex_bin(config, _default_codex_bin_resolver_ops()) + + +@dataclass(slots=True) +class AppServerConfig: + codex_bin: str | None = None + launch_args_override: tuple[str, ...] | None = None + config_overrides: tuple[str, ...] = () + cwd: str | None = None + env: dict[str, str] | None = None + client_name: str = "codex_python_sdk" + client_title: str = "Codex Python SDK" + client_version: str = "0.2.0" + experimental_api: bool = True + + +class AppServerClient: + """Synchronous typed JSON-RPC client for `codex app-server` over stdio.""" + + def __init__( + self, + config: AppServerConfig | None = None, + approval_handler: ApprovalHandler | None = None, + ) -> None: + self.config = config or AppServerConfig() + self._approval_handler = approval_handler or self._default_approval_handler + self._proc: subprocess.Popen[str] | None = None + self._lock = threading.Lock() + self._turn_consumer_lock = threading.Lock() + self._active_turn_consumer: str | None = None + self._pending_notifications: deque[Notification] = deque() + self._stderr_lines: deque[str] = deque(maxlen=400) + self._stderr_thread: threading.Thread | None = None + + def __enter__(self) -> "AppServerClient": + self.start() + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + def start(self) -> None: + if self._proc is not None: + return + + if self.config.launch_args_override is not None: + args = list(self.config.launch_args_override) + else: + codex_bin = _resolve_codex_bin(self.config) + args = [str(codex_bin)] + for kv in self.config.config_overrides: + args.extend(["--config", kv]) + args.extend(["app-server", "--listen", "stdio://"]) + + env = os.environ.copy() + if self.config.env: + env.update(self.config.env) + + self._proc = subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=self.config.cwd, + env=env, + bufsize=1, + ) + + self._start_stderr_drain_thread() + + def close(self) -> None: + if self._proc is None: + return + proc = self._proc + self._proc = None + self._active_turn_consumer = None + + if proc.stdin: + proc.stdin.close() + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + proc.kill() + + if self._stderr_thread and self._stderr_thread.is_alive(): + self._stderr_thread.join(timeout=0.5) + + def initialize(self) -> InitializeResponse: + result = self.request( + "initialize", + { + "clientInfo": { + "name": self.config.client_name, + "title": self.config.client_title, + "version": self.config.client_version, + }, + "capabilities": { + "experimentalApi": self.config.experimental_api, + }, + }, + response_model=InitializeResponse, + ) + self.notify("initialized", None) + return result + + def request( + self, + method: str, + params: JsonObject | None, + *, + response_model: type[ModelT], + ) -> ModelT: + result = self._request_raw(method, params) + if not isinstance(result, dict): + raise AppServerError(f"{method} response must be a JSON object") + return response_model.model_validate(result) + + def _request_raw(self, method: str, params: JsonObject | None = None) -> JsonValue: + request_id = str(uuid.uuid4()) + self._write_message({"id": request_id, "method": method, "params": params or {}}) + + while True: + msg = self._read_message() + + if "method" in msg and "id" in msg: + response = self._handle_server_request(msg) + self._write_message({"id": msg["id"], "result": response}) + continue + + if "method" in msg and "id" not in msg: + self._pending_notifications.append( + self._coerce_notification(msg["method"], msg.get("params")) + ) + continue + + if msg.get("id") != request_id: + continue + + if "error" in msg: + err = msg["error"] + if isinstance(err, dict): + raise map_jsonrpc_error( + int(err.get("code", -32000)), + str(err.get("message", "unknown")), + err.get("data"), + ) + raise AppServerError("Malformed JSON-RPC error response") + + return msg.get("result") + + def notify(self, method: str, params: JsonObject | None = None) -> None: + self._write_message({"method": method, "params": params or {}}) + + def next_notification(self) -> Notification: + if self._pending_notifications: + return self._pending_notifications.popleft() + + while True: + msg = self._read_message() + if "method" in msg and "id" in msg: + response = self._handle_server_request(msg) + self._write_message({"id": msg["id"], "result": response}) + continue + if "method" in msg and "id" not in msg: + return self._coerce_notification(msg["method"], msg.get("params")) + + def acquire_turn_consumer(self, turn_id: str) -> None: + with self._turn_consumer_lock: + if self._active_turn_consumer is not None: + raise RuntimeError( + "Concurrent turn consumers are not yet supported in the experimental SDK. " + f"Client is already streaming turn {self._active_turn_consumer!r}; " + f"cannot start turn {turn_id!r} until the active consumer finishes." + ) + self._active_turn_consumer = turn_id + + def release_turn_consumer(self, turn_id: str) -> None: + with self._turn_consumer_lock: + if self._active_turn_consumer == turn_id: + self._active_turn_consumer = None + + def thread_start(self, params: V2ThreadStartParams | JsonObject | None = None) -> ThreadStartResponse: + return self.request("thread/start", _params_dict(params), response_model=ThreadStartResponse) + + def thread_resume( + self, + thread_id: str, + params: V2ThreadResumeParams | JsonObject | None = None, + ) -> ThreadResumeResponse: + payload = {"threadId": thread_id, **_params_dict(params)} + return self.request("thread/resume", payload, response_model=ThreadResumeResponse) + + def thread_list(self, params: V2ThreadListParams | JsonObject | None = None) -> ThreadListResponse: + return self.request("thread/list", _params_dict(params), response_model=ThreadListResponse) + + def thread_read(self, thread_id: str, include_turns: bool = False) -> ThreadReadResponse: + return self.request( + "thread/read", + {"threadId": thread_id, "includeTurns": include_turns}, + response_model=ThreadReadResponse, + ) + + def thread_fork( + self, + thread_id: str, + params: V2ThreadForkParams | JsonObject | None = None, + ) -> ThreadForkResponse: + payload = {"threadId": thread_id, **_params_dict(params)} + return self.request("thread/fork", payload, response_model=ThreadForkResponse) + + def thread_archive(self, thread_id: str) -> ThreadArchiveResponse: + return self.request("thread/archive", {"threadId": thread_id}, response_model=ThreadArchiveResponse) + + def thread_unarchive(self, thread_id: str) -> ThreadUnarchiveResponse: + return self.request("thread/unarchive", {"threadId": thread_id}, response_model=ThreadUnarchiveResponse) + + def thread_set_name(self, thread_id: str, name: str) -> ThreadSetNameResponse: + return self.request( + "thread/name/set", + {"threadId": thread_id, "name": name}, + response_model=ThreadSetNameResponse, + ) + + def thread_compact(self, thread_id: str) -> ThreadCompactStartResponse: + return self.request( + "thread/compact/start", + {"threadId": thread_id}, + response_model=ThreadCompactStartResponse, + ) + + def turn_start( + self, + thread_id: str, + input_items: list[JsonObject] | JsonObject | str, + params: V2TurnStartParams | JsonObject | None = None, + ) -> TurnStartResponse: + payload = { + **_params_dict(params), + "threadId": thread_id, + "input": self._normalize_input_items(input_items), + } + return self.request("turn/start", payload, response_model=TurnStartResponse) + + def turn_interrupt(self, thread_id: str, turn_id: str) -> TurnInterruptResponse: + return self.request( + "turn/interrupt", + {"threadId": thread_id, "turnId": turn_id}, + response_model=TurnInterruptResponse, + ) + + def turn_steer( + self, + thread_id: str, + expected_turn_id: str, + input_items: list[JsonObject] | JsonObject | str, + ) -> TurnSteerResponse: + return self.request( + "turn/steer", + { + "threadId": thread_id, + "expectedTurnId": expected_turn_id, + "input": self._normalize_input_items(input_items), + }, + response_model=TurnSteerResponse, + ) + + def model_list(self, include_hidden: bool = False) -> ModelListResponse: + return self.request( + "model/list", + {"includeHidden": include_hidden}, + response_model=ModelListResponse, + ) + + def request_with_retry_on_overload( + self, + method: str, + params: JsonObject | None, + *, + response_model: type[ModelT], + max_attempts: int = 3, + initial_delay_s: float = 0.25, + max_delay_s: float = 2.0, + ) -> ModelT: + return retry_on_overload( + lambda: self.request(method, params, response_model=response_model), + max_attempts=max_attempts, + initial_delay_s=initial_delay_s, + max_delay_s=max_delay_s, + ) + + def wait_for_turn_completed(self, turn_id: str) -> TurnCompletedNotification: + while True: + notification = self.next_notification() + if ( + notification.method == "turn/completed" + and isinstance(notification.payload, TurnCompletedNotification) + and notification.payload.turn.id == turn_id + ): + return notification.payload + + def stream_until_methods(self, methods: Iterable[str] | str) -> list[Notification]: + target_methods = {methods} if isinstance(methods, str) else set(methods) + out: list[Notification] = [] + while True: + notification = self.next_notification() + out.append(notification) + if notification.method in target_methods: + return out + + def stream_text( + self, + thread_id: str, + text: str, + params: V2TurnStartParams | JsonObject | None = None, + ) -> Iterator[AgentMessageDeltaNotification]: + started = self.turn_start(thread_id, text, params=params) + turn_id = started.turn.id + while True: + notification = self.next_notification() + if ( + notification.method == "item/agentMessage/delta" + and isinstance(notification.payload, AgentMessageDeltaNotification) + and notification.payload.turn_id == turn_id + ): + yield notification.payload + continue + if ( + notification.method == "turn/completed" + and isinstance(notification.payload, TurnCompletedNotification) + and notification.payload.turn.id == turn_id + ): + break + + def _coerce_notification(self, method: str, params: object) -> Notification: + params_dict = params if isinstance(params, dict) else {} + + model = NOTIFICATION_MODELS.get(method) + if model is None: + return Notification(method=method, payload=UnknownNotification(params=params_dict)) + + try: + payload = model.model_validate(params_dict) + except Exception: # noqa: BLE001 + return Notification(method=method, payload=UnknownNotification(params=params_dict)) + return Notification(method=method, payload=payload) + + def _normalize_input_items( + self, + input_items: list[JsonObject] | JsonObject | str, + ) -> list[JsonObject]: + if isinstance(input_items, str): + return [{"type": "text", "text": input_items}] + if isinstance(input_items, dict): + return [input_items] + return input_items + + def _default_approval_handler(self, method: str, params: JsonObject | None) -> JsonObject: + if method == "item/commandExecution/requestApproval": + return {"decision": "accept"} + if method == "item/fileChange/requestApproval": + return {"decision": "accept"} + return {} + + def _start_stderr_drain_thread(self) -> None: + if self._proc is None or self._proc.stderr is None: + return + + def _drain() -> None: + stderr = self._proc.stderr + if stderr is None: + return + for line in stderr: + self._stderr_lines.append(line.rstrip("\n")) + + self._stderr_thread = threading.Thread(target=_drain, daemon=True) + self._stderr_thread.start() + + def _stderr_tail(self, limit: int = 40) -> str: + return "\n".join(list(self._stderr_lines)[-limit:]) + + def _handle_server_request(self, msg: dict[str, JsonValue]) -> JsonObject: + method = msg["method"] + params = msg.get("params") + if not isinstance(method, str): + return {} + return self._approval_handler( + method, + params if isinstance(params, dict) else None, + ) + + def _write_message(self, payload: JsonObject) -> None: + if self._proc is None or self._proc.stdin is None: + raise TransportClosedError("app-server is not running") + with self._lock: + self._proc.stdin.write(json.dumps(payload) + "\n") + self._proc.stdin.flush() + + def _read_message(self) -> dict[str, JsonValue]: + if self._proc is None or self._proc.stdout is None: + raise TransportClosedError("app-server is not running") + + line = self._proc.stdout.readline() + if not line: + raise TransportClosedError( + f"app-server closed stdout. stderr_tail={self._stderr_tail()[:2000]}" + ) + + try: + message = json.loads(line) + except json.JSONDecodeError as exc: + raise AppServerError(f"Invalid JSON-RPC line: {line!r}") from exc + + if not isinstance(message, dict): + raise AppServerError(f"Invalid JSON-RPC payload: {message!r}") + return message + + +def default_codex_home() -> str: + return str(Path.home() / ".codex") diff --git a/sdk/python/src/codex_app_server/errors.py b/sdk/python/src/codex_app_server/errors.py new file mode 100644 index 0000000000..24972e4fd7 --- /dev/null +++ b/sdk/python/src/codex_app_server/errors.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import Any + + +class AppServerError(Exception): + """Base exception for SDK errors.""" + + +class JsonRpcError(AppServerError): + """Raw JSON-RPC error wrapper from the server.""" + + def __init__(self, code: int, message: str, data: Any = None): + super().__init__(f"JSON-RPC error {code}: {message}") + self.code = code + self.message = message + self.data = data + + +class TransportClosedError(AppServerError): + """Raised when the app-server transport closes unexpectedly.""" + + +class AppServerRpcError(JsonRpcError): + """Base typed error for JSON-RPC failures.""" + + +class ParseError(AppServerRpcError): + pass + + +class InvalidRequestError(AppServerRpcError): + pass + + +class MethodNotFoundError(AppServerRpcError): + pass + + +class InvalidParamsError(AppServerRpcError): + pass + + +class InternalRpcError(AppServerRpcError): + pass + + +class ServerBusyError(AppServerRpcError): + """Server is overloaded / unavailable and caller should retry.""" + + +class RetryLimitExceededError(ServerBusyError): + """Server exhausted internal retry budget for a retryable operation.""" + + +def _contains_retry_limit_text(message: str) -> bool: + lowered = message.lower() + return "retry limit" in lowered or "too many failed attempts" in lowered + + +def _is_server_overloaded(data: Any) -> bool: + if data is None: + return False + + if isinstance(data, str): + return data.lower() == "server_overloaded" + + if isinstance(data, dict): + direct = ( + data.get("codex_error_info") + or data.get("codexErrorInfo") + or data.get("errorInfo") + ) + if isinstance(direct, str) and direct.lower() == "server_overloaded": + return True + if isinstance(direct, dict): + for value in direct.values(): + if isinstance(value, str) and value.lower() == "server_overloaded": + return True + for value in data.values(): + if _is_server_overloaded(value): + return True + + if isinstance(data, list): + return any(_is_server_overloaded(value) for value in data) + + return False + + +def map_jsonrpc_error(code: int, message: str, data: Any = None) -> JsonRpcError: + """Map a raw JSON-RPC error into a richer SDK exception class.""" + + if code == -32700: + return ParseError(code, message, data) + if code == -32600: + return InvalidRequestError(code, message, data) + if code == -32601: + return MethodNotFoundError(code, message, data) + if code == -32602: + return InvalidParamsError(code, message, data) + if code == -32603: + return InternalRpcError(code, message, data) + + if -32099 <= code <= -32000: + if _is_server_overloaded(data): + if _contains_retry_limit_text(message): + return RetryLimitExceededError(code, message, data) + return ServerBusyError(code, message, data) + if _contains_retry_limit_text(message): + return RetryLimitExceededError(code, message, data) + return AppServerRpcError(code, message, data) + + return JsonRpcError(code, message, data) + + +def is_retryable_error(exc: BaseException) -> bool: + """True if the exception is a transient overload-style error.""" + + if isinstance(exc, ServerBusyError): + return True + + if isinstance(exc, JsonRpcError): + return _is_server_overloaded(exc.data) + + return False diff --git a/sdk/python/src/codex_app_server/generated/__init__.py b/sdk/python/src/codex_app_server/generated/__init__.py new file mode 100644 index 0000000000..d7b3f674b2 --- /dev/null +++ b/sdk/python/src/codex_app_server/generated/__init__.py @@ -0,0 +1 @@ +"""Auto-generated Python types derived from the app-server schemas.""" diff --git a/sdk/python/src/codex_app_server/generated/notification_registry.py b/sdk/python/src/codex_app_server/generated/notification_registry.py new file mode 100644 index 0000000000..ef5b182d78 --- /dev/null +++ b/sdk/python/src/codex_app_server/generated/notification_registry.py @@ -0,0 +1,102 @@ +# Auto-generated by scripts/update_sdk_artifacts.py +# DO NOT EDIT MANUALLY. + +from __future__ import annotations + +from pydantic import BaseModel + +from .v2_all import AccountLoginCompletedNotification +from .v2_all import AccountRateLimitsUpdatedNotification +from .v2_all import AccountUpdatedNotification +from .v2_all import AgentMessageDeltaNotification +from .v2_all import AppListUpdatedNotification +from .v2_all import CommandExecOutputDeltaNotification +from .v2_all import CommandExecutionOutputDeltaNotification +from .v2_all import ConfigWarningNotification +from .v2_all import ContextCompactedNotification +from .v2_all import DeprecationNoticeNotification +from .v2_all import ErrorNotification +from .v2_all import FileChangeOutputDeltaNotification +from .v2_all import FuzzyFileSearchSessionCompletedNotification +from .v2_all import FuzzyFileSearchSessionUpdatedNotification +from .v2_all import HookCompletedNotification +from .v2_all import HookStartedNotification +from .v2_all import ItemCompletedNotification +from .v2_all import ItemStartedNotification +from .v2_all import McpServerOauthLoginCompletedNotification +from .v2_all import McpToolCallProgressNotification +from .v2_all import ModelReroutedNotification +from .v2_all import PlanDeltaNotification +from .v2_all import ReasoningSummaryPartAddedNotification +from .v2_all import ReasoningSummaryTextDeltaNotification +from .v2_all import ReasoningTextDeltaNotification +from .v2_all import ServerRequestResolvedNotification +from .v2_all import SkillsChangedNotification +from .v2_all import TerminalInteractionNotification +from .v2_all import ThreadArchivedNotification +from .v2_all import ThreadClosedNotification +from .v2_all import ThreadNameUpdatedNotification +from .v2_all import ThreadRealtimeClosedNotification +from .v2_all import ThreadRealtimeErrorNotification +from .v2_all import ThreadRealtimeItemAddedNotification +from .v2_all import ThreadRealtimeOutputAudioDeltaNotification +from .v2_all import ThreadRealtimeStartedNotification +from .v2_all import ThreadStartedNotification +from .v2_all import ThreadStatusChangedNotification +from .v2_all import ThreadTokenUsageUpdatedNotification +from .v2_all import ThreadUnarchivedNotification +from .v2_all import TurnCompletedNotification +from .v2_all import TurnDiffUpdatedNotification +from .v2_all import TurnPlanUpdatedNotification +from .v2_all import TurnStartedNotification +from .v2_all import WindowsSandboxSetupCompletedNotification +from .v2_all import WindowsWorldWritableWarningNotification + +NOTIFICATION_MODELS: dict[str, type[BaseModel]] = { + "account/login/completed": AccountLoginCompletedNotification, + "account/rateLimits/updated": AccountRateLimitsUpdatedNotification, + "account/updated": AccountUpdatedNotification, + "app/list/updated": AppListUpdatedNotification, + "command/exec/outputDelta": CommandExecOutputDeltaNotification, + "configWarning": ConfigWarningNotification, + "deprecationNotice": DeprecationNoticeNotification, + "error": ErrorNotification, + "fuzzyFileSearch/sessionCompleted": FuzzyFileSearchSessionCompletedNotification, + "fuzzyFileSearch/sessionUpdated": FuzzyFileSearchSessionUpdatedNotification, + "hook/completed": HookCompletedNotification, + "hook/started": HookStartedNotification, + "item/agentMessage/delta": AgentMessageDeltaNotification, + "item/commandExecution/outputDelta": CommandExecutionOutputDeltaNotification, + "item/commandExecution/terminalInteraction": TerminalInteractionNotification, + "item/completed": ItemCompletedNotification, + "item/fileChange/outputDelta": FileChangeOutputDeltaNotification, + "item/mcpToolCall/progress": McpToolCallProgressNotification, + "item/plan/delta": PlanDeltaNotification, + "item/reasoning/summaryPartAdded": ReasoningSummaryPartAddedNotification, + "item/reasoning/summaryTextDelta": ReasoningSummaryTextDeltaNotification, + "item/reasoning/textDelta": ReasoningTextDeltaNotification, + "item/started": ItemStartedNotification, + "mcpServer/oauthLogin/completed": McpServerOauthLoginCompletedNotification, + "model/rerouted": ModelReroutedNotification, + "serverRequest/resolved": ServerRequestResolvedNotification, + "skills/changed": SkillsChangedNotification, + "thread/archived": ThreadArchivedNotification, + "thread/closed": ThreadClosedNotification, + "thread/compacted": ContextCompactedNotification, + "thread/name/updated": ThreadNameUpdatedNotification, + "thread/realtime/closed": ThreadRealtimeClosedNotification, + "thread/realtime/error": ThreadRealtimeErrorNotification, + "thread/realtime/itemAdded": ThreadRealtimeItemAddedNotification, + "thread/realtime/outputAudio/delta": ThreadRealtimeOutputAudioDeltaNotification, + "thread/realtime/started": ThreadRealtimeStartedNotification, + "thread/started": ThreadStartedNotification, + "thread/status/changed": ThreadStatusChangedNotification, + "thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification, + "thread/unarchived": ThreadUnarchivedNotification, + "turn/completed": TurnCompletedNotification, + "turn/diff/updated": TurnDiffUpdatedNotification, + "turn/plan/updated": TurnPlanUpdatedNotification, + "turn/started": TurnStartedNotification, + "windows/worldWritableWarning": WindowsWorldWritableWarningNotification, + "windowsSandbox/setupCompleted": WindowsSandboxSetupCompletedNotification, +} diff --git a/sdk/python/src/codex_app_server/generated/v2_all.py b/sdk/python/src/codex_app_server/generated/v2_all.py new file mode 100644 index 0000000000..f746fc771f --- /dev/null +++ b/sdk/python/src/codex_app_server/generated/v2_all.py @@ -0,0 +1,8407 @@ +# generated by datamodel-codegen: +# filename: codex_app_server_protocol.v2.schemas.json + +from __future__ import annotations +from pydantic import BaseModel, ConfigDict, Field, RootModel +from typing import Annotated, Any, Literal +from enum import Enum + + +class CodexAppServerProtocolV2(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class AbsolutePathBuf(RootModel[str]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + str, + Field( + description="A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute." + ), + ] + + +class ApiKeyAccount(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["apiKey"], Field(title="ApiKeyAccountType")] + + +class AccountLoginCompletedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + error: str | None = None + login_id: Annotated[str | None, Field(alias="loginId")] = None + success: bool + + +class TextAgentMessageContent(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str + type: Annotated[Literal["Text"], Field(title="TextAgentMessageContentType")] + + +class AgentMessageContent(RootModel[TextAgentMessageContent]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: TextAgentMessageContent + + +class AgentMessageDeltaNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + item_id: Annotated[str, Field(alias="itemId")] + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class CompletedAgentStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + completed: str | None = None + + +class ErroredAgentStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + errored: str + + +class AgentStatus( + RootModel[ + Literal["pending_init"] + | Literal["running"] + | CompletedAgentStatus + | ErroredAgentStatus + | Literal["shutdown"] + | Literal["not_found"] + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + Literal["pending_init"] + | Literal["running"] + | CompletedAgentStatus + | ErroredAgentStatus + | Literal["shutdown"] + | Literal["not_found"], + Field(description="Agent lifecycle status, derived from emitted events."), + ] + + +class AnalyticsConfig(BaseModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + enabled: bool | None = None + + +class AppBranding(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + category: str | None = None + developer: str | None = None + is_discoverable_app: Annotated[bool, Field(alias="isDiscoverableApp")] + privacy_policy: Annotated[str | None, Field(alias="privacyPolicy")] = None + terms_of_service: Annotated[str | None, Field(alias="termsOfService")] = None + website: str | None = None + + +class AppReview(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + status: str + + +class AppScreenshot(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + file_id: Annotated[str | None, Field(alias="fileId")] = None + url: str | None = None + user_prompt: Annotated[str, Field(alias="userPrompt")] + + +class AppSummary(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: str | None = None + id: str + install_url: Annotated[str | None, Field(alias="installUrl")] = None + name: str + + +class AppToolApproval(Enum): + auto = "auto" + prompt = "prompt" + approve = "approve" + + +class AppToolConfig(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + approval_mode: AppToolApproval | None = None + enabled: bool | None = None + + +class AppToolsConfig(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class AppsDefaultConfig(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + destructive_enabled: bool | None = True + enabled: bool | None = True + open_world_enabled: bool | None = True + + +class AppsListParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cursor: Annotated[ + str | None, + Field(description="Opaque pagination cursor returned by a previous call."), + ] = None + force_refetch: Annotated[ + bool | None, + Field( + alias="forceRefetch", + description="When true, bypass app caches and fetch the latest data from sources.", + ), + ] = None + limit: Annotated[ + int | None, + Field( + description="Optional page size; defaults to a reasonable server-side value.", + ge=0, + ), + ] = None + thread_id: Annotated[ + str | None, + Field( + alias="threadId", + description="Optional thread id used to evaluate app feature gating from that thread's config.", + ), + ] = None + + +class AskForApprovalValue(Enum): + untrusted = "untrusted" + on_failure = "on-failure" + on_request = "on-request" + never = "never" + + +class Reject(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + mcp_elicitations: bool + request_permissions: bool | None = False + rules: bool + sandbox_approval: bool + skill_approval: bool | None = False + + +class RejectAskForApproval(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + reject: Reject + + +class AskForApproval(RootModel[AskForApprovalValue | RejectAskForApproval]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: AskForApprovalValue | RejectAskForApproval + + +class AuthMode(Enum): + apikey = "apikey" + chatgpt = "chatgpt" + chatgpt_auth_tokens = "chatgptAuthTokens" + + +class ByteRange(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + end: Annotated[int, Field(ge=0)] + start: Annotated[int, Field(ge=0)] + + +class CallToolResult(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + field_meta: Annotated[Any | None, Field(alias="_meta")] = None + content: list + is_error: Annotated[bool | None, Field(alias="isError")] = None + structured_content: Annotated[Any | None, Field(alias="structuredContent")] = None + + +class CancelLoginAccountParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + login_id: Annotated[str, Field(alias="loginId")] + + +class CancelLoginAccountStatus(Enum): + canceled = "canceled" + not_found = "notFound" + + +class ClientInfo(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + name: str + title: str | None = None + version: str + + +class CodexErrorInfoValue(Enum): + context_window_exceeded = "contextWindowExceeded" + usage_limit_exceeded = "usageLimitExceeded" + server_overloaded = "serverOverloaded" + internal_server_error = "internalServerError" + unauthorized = "unauthorized" + bad_request = "badRequest" + thread_rollback_failed = "threadRollbackFailed" + sandbox_error = "sandboxError" + other = "other" + + +class HttpConnectionFailed(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + http_status_code: Annotated[int | None, Field(alias="httpStatusCode", ge=0)] = None + + +class HttpConnectionFailedCodexErrorInfo(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + http_connection_failed: Annotated[ + HttpConnectionFailed, Field(alias="httpConnectionFailed") + ] + + +class ResponseStreamConnectionFailed(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + http_status_code: Annotated[int | None, Field(alias="httpStatusCode", ge=0)] = None + + +class ResponseStreamConnectionFailedCodexErrorInfo(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + response_stream_connection_failed: Annotated[ + ResponseStreamConnectionFailed, Field(alias="responseStreamConnectionFailed") + ] + + +class ResponseStreamDisconnected(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + http_status_code: Annotated[int | None, Field(alias="httpStatusCode", ge=0)] = None + + +class ResponseStreamDisconnectedCodexErrorInfo(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + response_stream_disconnected: Annotated[ + ResponseStreamDisconnected, Field(alias="responseStreamDisconnected") + ] + + +class ResponseTooManyFailedAttempts(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + http_status_code: Annotated[int | None, Field(alias="httpStatusCode", ge=0)] = None + + +class ResponseTooManyFailedAttemptsCodexErrorInfo(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + response_too_many_failed_attempts: Annotated[ + ResponseTooManyFailedAttempts, Field(alias="responseTooManyFailedAttempts") + ] + + +class CodexErrorInfo( + RootModel[ + CodexErrorInfoValue + | HttpConnectionFailedCodexErrorInfo + | ResponseStreamConnectionFailedCodexErrorInfo + | ResponseStreamDisconnectedCodexErrorInfo + | ResponseTooManyFailedAttemptsCodexErrorInfo + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + CodexErrorInfoValue + | HttpConnectionFailedCodexErrorInfo + | ResponseStreamConnectionFailedCodexErrorInfo + | ResponseStreamDisconnectedCodexErrorInfo + | ResponseTooManyFailedAttemptsCodexErrorInfo, + Field( + description="This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant." + ), + ] + + +class CollabAgentStatus(Enum): + pending_init = "pendingInit" + running = "running" + completed = "completed" + errored = "errored" + shutdown = "shutdown" + not_found = "notFound" + + +class CollabAgentTool(Enum): + spawn_agent = "spawnAgent" + send_input = "sendInput" + resume_agent = "resumeAgent" + wait = "wait" + close_agent = "closeAgent" + + +class CollabAgentToolCallStatus(Enum): + in_progress = "inProgress" + completed = "completed" + failed = "failed" + + +class ReadCommandAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + command: str + name: str + path: str + type: Annotated[Literal["read"], Field(title="ReadCommandActionType")] + + +class ListFilesCommandAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + command: str + path: str | None = None + type: Annotated[Literal["listFiles"], Field(title="ListFilesCommandActionType")] + + +class SearchCommandAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + command: str + path: str | None = None + query: str | None = None + type: Annotated[Literal["search"], Field(title="SearchCommandActionType")] + + +class UnknownCommandAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + command: str + type: Annotated[Literal["unknown"], Field(title="UnknownCommandActionType")] + + +class CommandAction( + RootModel[ + ReadCommandAction + | ListFilesCommandAction + | SearchCommandAction + | UnknownCommandAction + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + ReadCommandAction + | ListFilesCommandAction + | SearchCommandAction + | UnknownCommandAction + ) + + +class CommandExecOutputStream(Enum): + stdout = "stdout" + stderr = "stderr" + + +class CommandExecResizeResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class CommandExecResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + exit_code: Annotated[int, Field(alias="exitCode", description="Process exit code.")] + stderr: Annotated[ + str, + Field( + description="Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`." + ), + ] + stdout: Annotated[ + str, + Field( + description="Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`." + ), + ] + + +class CommandExecTerminalSize(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cols: Annotated[int, Field(description="Terminal width in character cells.", ge=0)] + rows: Annotated[int, Field(description="Terminal height in character cells.", ge=0)] + + +class CommandExecTerminateParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + process_id: Annotated[ + str, + Field( + alias="processId", + description="Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + ), + ] + + +class CommandExecTerminateResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class CommandExecWriteParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + close_stdin: Annotated[ + bool | None, + Field( + alias="closeStdin", + description="Close stdin after writing `deltaBase64`, if present.", + ), + ] = None + delta_base64: Annotated[ + str | None, + Field( + alias="deltaBase64", + description="Optional base64-encoded stdin bytes to write.", + ), + ] = None + process_id: Annotated[ + str, + Field( + alias="processId", + description="Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + ), + ] + + +class CommandExecWriteResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class CommandExecutionOutputDeltaNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + item_id: Annotated[str, Field(alias="itemId")] + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class CommandExecutionStatus(Enum): + in_progress = "inProgress" + completed = "completed" + failed = "failed" + declined = "declined" + + +class MdmConfigLayerSource(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + domain: str + key: str + type: Annotated[Literal["mdm"], Field(title="MdmConfigLayerSourceType")] + + +class SystemConfigLayerSource(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + file: Annotated[ + AbsolutePathBuf, + Field( + description="This is the path to the system config.toml file, though it is not guaranteed to exist." + ), + ] + type: Annotated[Literal["system"], Field(title="SystemConfigLayerSourceType")] + + +class UserConfigLayerSource(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + file: Annotated[ + AbsolutePathBuf, + Field( + description="This is the path to the user's config.toml file, though it is not guaranteed to exist." + ), + ] + type: Annotated[Literal["user"], Field(title="UserConfigLayerSourceType")] + + +class ProjectConfigLayerSource(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + dot_codex_folder: Annotated[AbsolutePathBuf, Field(alias="dotCodexFolder")] + type: Annotated[Literal["project"], Field(title="ProjectConfigLayerSourceType")] + + +class SessionFlagsConfigLayerSource(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["sessionFlags"], Field(title="SessionFlagsConfigLayerSourceType") + ] + + +class LegacyManagedConfigTomlFromFileConfigLayerSource(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + file: AbsolutePathBuf + type: Annotated[ + Literal["legacyManagedConfigTomlFromFile"], + Field(title="LegacyManagedConfigTomlFromFileConfigLayerSourceType"), + ] + + +class LegacyManagedConfigTomlFromMdmConfigLayerSource(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["legacyManagedConfigTomlFromMdm"], + Field(title="LegacyManagedConfigTomlFromMdmConfigLayerSourceType"), + ] + + +class ConfigLayerSource( + RootModel[ + MdmConfigLayerSource + | SystemConfigLayerSource + | UserConfigLayerSource + | ProjectConfigLayerSource + | SessionFlagsConfigLayerSource + | LegacyManagedConfigTomlFromFileConfigLayerSource + | LegacyManagedConfigTomlFromMdmConfigLayerSource + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + MdmConfigLayerSource + | SystemConfigLayerSource + | UserConfigLayerSource + | ProjectConfigLayerSource + | SessionFlagsConfigLayerSource + | LegacyManagedConfigTomlFromFileConfigLayerSource + | LegacyManagedConfigTomlFromMdmConfigLayerSource + ) + + +class ConfigReadParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cwd: Annotated[ + str | None, + Field( + description="Optional working directory to resolve project config layers. If specified, return the effective config as seen from that directory (i.e., including any project layers between `cwd` and the project/repo root)." + ), + ] = None + include_layers: Annotated[bool | None, Field(alias="includeLayers")] = False + + +class InputTextContentItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str + type: Annotated[Literal["input_text"], Field(title="InputTextContentItemType")] + + +class InputImageContentItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + image_url: str + type: Annotated[Literal["input_image"], Field(title="InputImageContentItemType")] + + +class OutputTextContentItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str + type: Annotated[Literal["output_text"], Field(title="OutputTextContentItemType")] + + +class ContentItem( + RootModel[InputTextContentItem | InputImageContentItem | OutputTextContentItem] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: InputTextContentItem | InputImageContentItem | OutputTextContentItem + + +class ContextCompactedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class CreditsSnapshot(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + balance: str | None = None + has_credits: Annotated[bool, Field(alias="hasCredits")] + unlimited: bool + + +class CustomPrompt(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + argument_hint: str | None = None + content: str + description: str | None = None + name: str + path: str + + +class DeprecationNoticeNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + details: Annotated[ + str | None, + Field( + description="Optional extra guidance, such as migration steps or rationale." + ), + ] = None + summary: Annotated[str, Field(description="Concise summary of what is deprecated.")] + + +class Duration(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + nanos: Annotated[int, Field(ge=0)] + secs: Annotated[int, Field(ge=0)] + + +class InputTextDynamicToolCallOutputContentItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str + type: Annotated[ + Literal["inputText"], + Field(title="InputTextDynamicToolCallOutputContentItemType"), + ] + + +class InputImageDynamicToolCallOutputContentItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + image_url: Annotated[str, Field(alias="imageUrl")] + type: Annotated[ + Literal["inputImage"], + Field(title="InputImageDynamicToolCallOutputContentItemType"), + ] + + +class DynamicToolCallOutputContentItem( + RootModel[ + InputTextDynamicToolCallOutputContentItem + | InputImageDynamicToolCallOutputContentItem + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + InputTextDynamicToolCallOutputContentItem + | InputImageDynamicToolCallOutputContentItem + ) + + +class DynamicToolCallStatus(Enum): + in_progress = "inProgress" + completed = "completed" + failed = "failed" + + +class DynamicToolSpec(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + input_schema: Annotated[Any, Field(alias="inputSchema")] + name: str + + +class FormElicitationRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + field_meta: Annotated[Any | None, Field(alias="_meta")] = None + message: str + mode: Annotated[Literal["form"], Field(title="FormElicitationRequestMode")] + requested_schema: Any + + +class UrlElicitationRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + field_meta: Annotated[Any | None, Field(alias="_meta")] = None + elicitation_id: str + message: str + mode: Annotated[Literal["url"], Field(title="UrlElicitationRequestMode")] + url: str + + +class ElicitationRequest(RootModel[FormElicitationRequest | UrlElicitationRequest]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: FormElicitationRequest | UrlElicitationRequest + + +class ErrorEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + codex_error_info: CodexErrorInfo | None = None + message: str + type: Annotated[Literal["error"], Field(title="ErrorEventMsgType")] + + +class WarningEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + message: str + type: Annotated[Literal["warning"], Field(title="WarningEventMsgType")] + + +class RealtimeConversationStartedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + session_id: str | None = None + type: Annotated[ + Literal["realtime_conversation_started"], + Field(title="RealtimeConversationStartedEventMsgType"), + ] + + +class RealtimeConversationClosedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + reason: str | None = None + type: Annotated[ + Literal["realtime_conversation_closed"], + Field(title="RealtimeConversationClosedEventMsgType"), + ] + + +class ContextCompactedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["context_compacted"], Field(title="ContextCompactedEventMsgType") + ] + + +class ThreadRolledBackEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + num_turns: Annotated[ + int, + Field(description="Number of user turns that were removed from context.", ge=0), + ] + type: Annotated[ + Literal["thread_rolled_back"], Field(title="ThreadRolledBackEventMsgType") + ] + + +class TaskCompleteEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + last_agent_message: str | None = None + turn_id: str + type: Annotated[Literal["task_complete"], Field(title="TaskCompleteEventMsgType")] + + +class AgentMessageDeltaEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + type: Annotated[ + Literal["agent_message_delta"], Field(title="AgentMessageDeltaEventMsgType") + ] + + +class AgentReasoningEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str + type: Annotated[ + Literal["agent_reasoning"], Field(title="AgentReasoningEventMsgType") + ] + + +class AgentReasoningDeltaEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + type: Annotated[ + Literal["agent_reasoning_delta"], Field(title="AgentReasoningDeltaEventMsgType") + ] + + +class AgentReasoningRawContentEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str + type: Annotated[ + Literal["agent_reasoning_raw_content"], + Field(title="AgentReasoningRawContentEventMsgType"), + ] + + +class AgentReasoningRawContentDeltaEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + type: Annotated[ + Literal["agent_reasoning_raw_content_delta"], + Field(title="AgentReasoningRawContentDeltaEventMsgType"), + ] + + +class AgentReasoningSectionBreakEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item_id: str | None = "" + summary_index: int | None = 0 + type: Annotated[ + Literal["agent_reasoning_section_break"], + Field(title="AgentReasoningSectionBreakEventMsgType"), + ] + + +class WebSearchBeginEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: str + type: Annotated[ + Literal["web_search_begin"], Field(title="WebSearchBeginEventMsgType") + ] + + +class ImageGenerationBeginEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: str + type: Annotated[ + Literal["image_generation_begin"], + Field(title="ImageGenerationBeginEventMsgType"), + ] + + +class ImageGenerationEndEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: str + result: str + revised_prompt: str | None = None + saved_path: str | None = None + status: str + type: Annotated[ + Literal["image_generation_end"], Field(title="ImageGenerationEndEventMsgType") + ] + + +class TerminalInteractionEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[ + str, + Field( + description="Identifier for the ExecCommandBegin that produced this chunk." + ), + ] + process_id: Annotated[ + str, Field(description="Process id associated with the running command.") + ] + stdin: Annotated[str, Field(description="Stdin sent to the running session.")] + type: Annotated[ + Literal["terminal_interaction"], Field(title="TerminalInteractionEventMsgType") + ] + + +class ViewImageToolCallEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[ + str, Field(description="Identifier for the originating tool call.") + ] + path: Annotated[ + str, Field(description="Local filesystem path provided to the tool.") + ] + type: Annotated[ + Literal["view_image_tool_call"], Field(title="ViewImageToolCallEventMsgType") + ] + + +class DynamicToolCallRequestEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + arguments: Any + call_id: Annotated[str, Field(alias="callId")] + tool: str + turn_id: Annotated[str, Field(alias="turnId")] + type: Annotated[ + Literal["dynamic_tool_call_request"], + Field(title="DynamicToolCallRequestEventMsgType"), + ] + + +class DynamicToolCallResponseEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + arguments: Annotated[Any, Field(description="Dynamic tool call arguments.")] + call_id: Annotated[ + str, + Field(description="Identifier for the corresponding DynamicToolCallRequest."), + ] + content_items: Annotated[ + list[DynamicToolCallOutputContentItem], + Field(description="Dynamic tool response content items."), + ] + duration: Annotated[ + Duration, Field(description="The duration of the dynamic tool call.") + ] + error: Annotated[ + str | None, + Field( + description="Optional error text when the tool call failed before producing a response." + ), + ] = None + success: Annotated[bool, Field(description="Whether the tool call succeeded.")] + tool: Annotated[str, Field(description="Dynamic tool name.")] + turn_id: Annotated[ + str, Field(description="Turn ID that this dynamic tool call belongs to.") + ] + type: Annotated[ + Literal["dynamic_tool_call_response"], + Field(title="DynamicToolCallResponseEventMsgType"), + ] + + +class DeprecationNoticeEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + details: Annotated[ + str | None, + Field( + description="Optional extra guidance, such as migration steps or rationale." + ), + ] = None + summary: Annotated[str, Field(description="Concise summary of what is deprecated.")] + type: Annotated[ + Literal["deprecation_notice"], Field(title="DeprecationNoticeEventMsgType") + ] + + +class BackgroundEventEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + message: str + type: Annotated[ + Literal["background_event"], Field(title="BackgroundEventEventMsgType") + ] + + +class UndoStartedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + message: str | None = None + type: Annotated[Literal["undo_started"], Field(title="UndoStartedEventMsgType")] + + +class UndoCompletedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + message: str | None = None + success: bool + type: Annotated[Literal["undo_completed"], Field(title="UndoCompletedEventMsgType")] + + +class StreamErrorEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + additional_details: Annotated[ + str | None, + Field( + description="Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted)." + ), + ] = None + codex_error_info: CodexErrorInfo | None = None + message: str + type: Annotated[Literal["stream_error"], Field(title="StreamErrorEventMsgType")] + + +class TurnDiffEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["turn_diff"], Field(title="TurnDiffEventMsgType")] + unified_diff: str + + +class ListCustomPromptsResponseEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + custom_prompts: list[CustomPrompt] + type: Annotated[ + Literal["list_custom_prompts_response"], + Field(title="ListCustomPromptsResponseEventMsgType"), + ] + + +class RemoteSkillDownloadedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + name: str + path: str + type: Annotated[ + Literal["remote_skill_downloaded"], + Field(title="RemoteSkillDownloadedEventMsgType"), + ] + + +class SkillsUpdateAvailableEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["skills_update_available"], + Field(title="SkillsUpdateAvailableEventMsgType"), + ] + + +class ShutdownCompleteEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["shutdown_complete"], Field(title="ShutdownCompleteEventMsgType") + ] + + +class AgentMessageContentDeltaEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + item_id: str + thread_id: str + turn_id: str + type: Annotated[ + Literal["agent_message_content_delta"], + Field(title="AgentMessageContentDeltaEventMsgType"), + ] + + +class PlanDeltaEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + item_id: str + thread_id: str + turn_id: str + type: Annotated[Literal["plan_delta"], Field(title="PlanDeltaEventMsgType")] + + +class ReasoningContentDeltaEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + item_id: str + summary_index: int | None = 0 + thread_id: str + turn_id: str + type: Annotated[ + Literal["reasoning_content_delta"], + Field(title="ReasoningContentDeltaEventMsgType"), + ] + + +class ReasoningRawContentDeltaEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + content_index: int | None = 0 + delta: str + item_id: str + thread_id: str + turn_id: str + type: Annotated[ + Literal["reasoning_raw_content_delta"], + Field(title="ReasoningRawContentDeltaEventMsgType"), + ] + + +class ExecApprovalRequestSkillMetadata(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + path_to_skills_md: str + + +class ExecCommandSource(Enum): + agent = "agent" + user_shell = "user_shell" + unified_exec_startup = "unified_exec_startup" + unified_exec_interaction = "unified_exec_interaction" + + +class ExecCommandStatus(Enum): + completed = "completed" + failed = "failed" + declined = "declined" + + +class ExecOutputStream(Enum): + stdout = "stdout" + stderr = "stderr" + + +class ExperimentalFeatureListParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cursor: Annotated[ + str | None, + Field(description="Opaque pagination cursor returned by a previous call."), + ] = None + limit: Annotated[ + int | None, + Field( + description="Optional page size; defaults to a reasonable server-side value.", + ge=0, + ), + ] = None + + +class ExperimentalFeatureStage(Enum): + beta = "beta" + under_development = "underDevelopment" + stable = "stable" + deprecated = "deprecated" + removed = "removed" + + +class ExternalAgentConfigDetectParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cwds: Annotated[ + list[str] | None, + Field( + description="Zero or more working directories to include for repo-scoped detection." + ), + ] = None + include_home: Annotated[ + bool | None, + Field( + alias="includeHome", + description="If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + ), + ] = None + + +class ExternalAgentConfigImportResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class ExternalAgentConfigMigrationItemType(Enum): + agents_md = "AGENTS_MD" + config = "CONFIG" + skills = "SKILLS" + mcp_server_config = "MCP_SERVER_CONFIG" + + +class FeedbackUploadParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + classification: str + extra_log_files: Annotated[list[str] | None, Field(alias="extraLogFiles")] = None + include_logs: Annotated[bool, Field(alias="includeLogs")] + reason: str | None = None + thread_id: Annotated[str | None, Field(alias="threadId")] = None + + +class FeedbackUploadResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + +class AddFileChange(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + content: str + type: Annotated[Literal["add"], Field(title="AddFileChangeType")] + + +class DeleteFileChange(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + content: str + type: Annotated[Literal["delete"], Field(title="DeleteFileChangeType")] + + +class UpdateFileChange(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + move_path: str | None = None + type: Annotated[Literal["update"], Field(title="UpdateFileChangeType")] + unified_diff: str + + +class FileChange(RootModel[AddFileChange | DeleteFileChange | UpdateFileChange]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: AddFileChange | DeleteFileChange | UpdateFileChange + + +class FileChangeOutputDeltaNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + item_id: Annotated[str, Field(alias="itemId")] + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class FileSystemPermissions(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + read: list[AbsolutePathBuf] | None = None + write: list[AbsolutePathBuf] | None = None + + +class ForcedLoginMethod(Enum): + chatgpt = "chatgpt" + api = "api" + + +class InputTextFunctionCallOutputContentItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str + type: Annotated[ + Literal["input_text"], Field(title="InputTextFunctionCallOutputContentItemType") + ] + + +class FuzzyFileSearchParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cancellation_token: Annotated[str | None, Field(alias="cancellationToken")] = None + query: str + roots: list[str] + + +class Indice(RootModel[int]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[int, Field(ge=0)] + + +class FuzzyFileSearchResult(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + file_name: str + indices: list[Indice] | None = None + path: str + root: str + score: Annotated[int, Field(ge=0)] + + +class FuzzyFileSearchSessionCompletedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + session_id: Annotated[str, Field(alias="sessionId")] + + +class FuzzyFileSearchSessionUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + files: list[FuzzyFileSearchResult] + query: str + session_id: Annotated[str, Field(alias="sessionId")] + + +class GetAccountParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + refresh_token: Annotated[ + bool | None, + Field( + alias="refreshToken", + description="When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", + ), + ] = False + + +class GhostCommit(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + parent: str | None = None + preexisting_untracked_dirs: list[str] + preexisting_untracked_files: list[str] + + +class GitInfo(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + branch: str | None = None + origin_url: Annotated[str | None, Field(alias="originUrl")] = None + sha: str | None = None + + +class HazelnutScope(Enum): + example = "example" + workspace_shared = "workspace-shared" + all_shared = "all-shared" + personal = "personal" + + +class HistoryEntry(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + conversation_id: str + text: str + ts: Annotated[int, Field(ge=0)] + + +class HookEventName(Enum): + session_start = "sessionStart" + stop = "stop" + + +class HookExecutionMode(Enum): + sync = "sync" + async_ = "async" + + +class HookHandlerType(Enum): + command = "command" + prompt = "prompt" + agent = "agent" + + +class HookOutputEntryKind(Enum): + warning = "warning" + stop = "stop" + feedback = "feedback" + context = "context" + error = "error" + + +class HookRunStatus(Enum): + running = "running" + completed = "completed" + failed = "failed" + blocked = "blocked" + stopped = "stopped" + + +class HookScope(Enum): + thread = "thread" + turn = "turn" + + +class ImageDetail(Enum): + auto = "auto" + low = "low" + high = "high" + original = "original" + + +class InitializeCapabilities(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + experimental_api: Annotated[ + bool | None, + Field( + alias="experimentalApi", + description="Opt into receiving experimental API methods and fields.", + ), + ] = False + opt_out_notification_methods: Annotated[ + list[str] | None, + Field( + alias="optOutNotificationMethods", + description="Exact notification method names that should be suppressed for this connection (for example `thread/started`).", + ), + ] = None + + +class InitializeParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + capabilities: InitializeCapabilities | None = None + client_info: Annotated[ClientInfo, Field(alias="clientInfo")] + + +class InputModality(Enum): + text = "text" + image = "image" + + +class ListMcpServerStatusParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cursor: Annotated[ + str | None, + Field(description="Opaque pagination cursor returned by a previous call."), + ] = None + limit: Annotated[ + int | None, + Field( + description="Optional page size; defaults to a server-defined value.", ge=0 + ), + ] = None + + +class ExecLocalShellAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + command: list[str] + env: dict[str, Any] | None = None + timeout_ms: Annotated[int | None, Field(ge=0)] = None + type: Annotated[Literal["exec"], Field(title="ExecLocalShellActionType")] + user: str | None = None + working_directory: str | None = None + + +class LocalShellAction(RootModel[ExecLocalShellAction]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ExecLocalShellAction + + +class LocalShellStatus(Enum): + completed = "completed" + in_progress = "in_progress" + incomplete = "incomplete" + + +class ApiKeyLoginAccountParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + api_key: Annotated[str, Field(alias="apiKey")] + type: Annotated[Literal["apiKey"], Field(title="ApiKeyv2::LoginAccountParamsType")] + + +class ChatgptLoginAccountParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["chatgpt"], Field(title="Chatgptv2::LoginAccountParamsType") + ] + + +class ChatgptAuthTokensLoginAccountParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + access_token: Annotated[ + str, + Field( + alias="accessToken", + description="Access token (JWT) supplied by the client. This token is used for backend API requests and email extraction.", + ), + ] + chatgpt_account_id: Annotated[ + str, + Field( + alias="chatgptAccountId", + description="Workspace/account identifier supplied by the client.", + ), + ] + chatgpt_plan_type: Annotated[ + str | None, + Field( + alias="chatgptPlanType", + description="Optional plan type supplied by the client.\n\nWhen `null`, Codex attempts to derive the plan type from access-token claims. If unavailable, the plan defaults to `unknown`.", + ), + ] = None + type: Annotated[ + Literal["chatgptAuthTokens"], + Field(title="ChatgptAuthTokensv2::LoginAccountParamsType"), + ] + + +class LoginAccountParams( + RootModel[ + ApiKeyLoginAccountParams + | ChatgptLoginAccountParams + | ChatgptAuthTokensLoginAccountParams + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + ApiKeyLoginAccountParams + | ChatgptLoginAccountParams + | ChatgptAuthTokensLoginAccountParams, + Field(title="LoginAccountParams"), + ] + + +class ApiKeyLoginAccountResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["apiKey"], Field(title="ApiKeyv2::LoginAccountResponseType") + ] + + +class ChatgptLoginAccountResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + auth_url: Annotated[ + str, + Field( + alias="authUrl", + description="URL the client should open in a browser to initiate the OAuth flow.", + ), + ] + login_id: Annotated[str, Field(alias="loginId")] + type: Annotated[ + Literal["chatgpt"], Field(title="Chatgptv2::LoginAccountResponseType") + ] + + +class ChatgptAuthTokensLoginAccountResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["chatgptAuthTokens"], + Field(title="ChatgptAuthTokensv2::LoginAccountResponseType"), + ] + + +class LoginAccountResponse( + RootModel[ + ApiKeyLoginAccountResponse + | ChatgptLoginAccountResponse + | ChatgptAuthTokensLoginAccountResponse + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + ApiKeyLoginAccountResponse + | ChatgptLoginAccountResponse + | ChatgptAuthTokensLoginAccountResponse, + Field(title="LoginAccountResponse"), + ] + + +class LogoutAccountResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class MacOsAutomationPermissionValue(Enum): + none = "none" + all = "all" + + +class BundleIdsMacOsAutomationPermission(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + bundle_ids: list[str] + + +class MacOsAutomationPermission( + RootModel[MacOsAutomationPermissionValue | BundleIdsMacOsAutomationPermission] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: MacOsAutomationPermissionValue | BundleIdsMacOsAutomationPermission + + +class MacOsContactsPermission(Enum): + none = "none" + read_only = "read_only" + read_write = "read_write" + + +class MacOsPreferencesPermission(Enum): + none = "none" + read_only = "read_only" + read_write = "read_write" + + +class MacOsSeatbeltProfileExtensions(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + macos_accessibility: bool | None = False + macos_automation: Annotated[MacOsAutomationPermission | None, Field()] = "none" + macos_calendar: bool | None = False + macos_contacts: MacOsContactsPermission | None = "none" + macos_launch_services: bool | None = False + macos_preferences: MacOsPreferencesPermission | None = "read_only" + macos_reminders: bool | None = False + + +class McpAuthStatus(Enum): + unsupported = "unsupported" + not_logged_in = "notLoggedIn" + bearer_token = "bearerToken" + o_auth = "oAuth" + + +class McpInvocation(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + arguments: Annotated[ + Any | None, Field(description="Arguments to the tool call.") + ] = None + server: Annotated[ + str, Field(description="Name of the MCP server as defined in the config.") + ] + tool: Annotated[ + str, Field(description="Name of the tool as given by the MCP server.") + ] + + +class McpServerOauthLoginCompletedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + error: str | None = None + name: str + success: bool + + +class McpServerOauthLoginParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + name: str + scopes: list[str] | None = None + timeout_secs: Annotated[int | None, Field(alias="timeoutSecs")] = None + + +class McpServerOauthLoginResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + authorization_url: Annotated[str, Field(alias="authorizationUrl")] + + +class McpServerRefreshResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class McpStartupFailure(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + error: str + server: str + + +class StartingMcpStartupStatus(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + state: Annotated[Literal["starting"], Field(title="StartingMcpStartupStatusState")] + + +class ReadyMcpStartupStatus(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + state: Annotated[Literal["ready"], Field(title="ReadyMcpStartupStatusState")] + + +class FailedMcpStartupStatus(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + error: str + state: Annotated[Literal["failed"], Field(title="FailedMcpStartupStatusState")] + + +class CancelledMcpStartupStatus(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + state: Annotated[ + Literal["cancelled"], Field(title="CancelledMcpStartupStatusState") + ] + + +class McpStartupStatus( + RootModel[ + StartingMcpStartupStatus + | ReadyMcpStartupStatus + | FailedMcpStartupStatus + | CancelledMcpStartupStatus + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + StartingMcpStartupStatus + | ReadyMcpStartupStatus + | FailedMcpStartupStatus + | CancelledMcpStartupStatus + ) + + +class McpToolCallError(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + message: str + + +class McpToolCallProgressNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item_id: Annotated[str, Field(alias="itemId")] + message: str + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class McpToolCallResult(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + content: list + structured_content: Annotated[Any | None, Field(alias="structuredContent")] = None + + +class McpToolCallStatus(Enum): + in_progress = "inProgress" + completed = "completed" + failed = "failed" + + +class MergeStrategy(Enum): + replace = "replace" + upsert = "upsert" + + +class MessagePhase(Enum): + commentary = "commentary" + final_answer = "final_answer" + + +class ModeKind(Enum): + plan = "plan" + default = "default" + + +class ModelAvailabilityNux(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + message: str + + +class ModelListParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cursor: Annotated[ + str | None, + Field(description="Opaque pagination cursor returned by a previous call."), + ] = None + include_hidden: Annotated[ + bool | None, + Field( + alias="includeHidden", + description="When true, include models that are hidden from the default picker list.", + ), + ] = None + limit: Annotated[ + int | None, + Field( + description="Optional page size; defaults to a reasonable server-side value.", + ge=0, + ), + ] = None + + +class ModelRerouteReason(RootModel[Literal["highRiskCyberActivity"]]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Literal["highRiskCyberActivity"] + + +class ModelReroutedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + from_model: Annotated[str, Field(alias="fromModel")] + reason: ModelRerouteReason + thread_id: Annotated[str, Field(alias="threadId")] + to_model: Annotated[str, Field(alias="toModel")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class ModelUpgradeInfo(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + migration_markdown: Annotated[str | None, Field(alias="migrationMarkdown")] = None + model: str + model_link: Annotated[str | None, Field(alias="modelLink")] = None + upgrade_copy: Annotated[str | None, Field(alias="upgradeCopy")] = None + + +class NetworkAccess(Enum): + restricted = "restricted" + enabled = "enabled" + + +class NetworkApprovalProtocol(Enum): + http = "http" + https = "https" + socks5_tcp = "socks5Tcp" + socks5_udp = "socks5Udp" + + +class NetworkPermissions(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + enabled: bool | None = None + + +class NetworkPolicyRuleAction(Enum): + allow = "allow" + deny = "deny" + + +class NetworkRequirements(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + allow_local_binding: Annotated[bool | None, Field(alias="allowLocalBinding")] = None + allow_unix_sockets: Annotated[list[str] | None, Field(alias="allowUnixSockets")] = ( + None + ) + allow_upstream_proxy: Annotated[bool | None, Field(alias="allowUpstreamProxy")] = ( + None + ) + allowed_domains: Annotated[list[str] | None, Field(alias="allowedDomains")] = None + dangerously_allow_all_unix_sockets: Annotated[ + bool | None, Field(alias="dangerouslyAllowAllUnixSockets") + ] = None + dangerously_allow_non_loopback_proxy: Annotated[ + bool | None, Field(alias="dangerouslyAllowNonLoopbackProxy") + ] = None + denied_domains: Annotated[list[str] | None, Field(alias="deniedDomains")] = None + enabled: bool | None = None + http_port: Annotated[int | None, Field(alias="httpPort", ge=0)] = None + socks_port: Annotated[int | None, Field(alias="socksPort", ge=0)] = None + + +class ReadParsedCommand(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cmd: str + name: str + path: Annotated[ + str, + Field( + description="(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path." + ), + ] + type: Annotated[Literal["read"], Field(title="ReadParsedCommandType")] + + +class ListFilesParsedCommand(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cmd: str + path: str | None = None + type: Annotated[Literal["list_files"], Field(title="ListFilesParsedCommandType")] + + +class SearchParsedCommand(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cmd: str + path: str | None = None + query: str | None = None + type: Annotated[Literal["search"], Field(title="SearchParsedCommandType")] + + +class UnknownParsedCommand(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cmd: str + type: Annotated[Literal["unknown"], Field(title="UnknownParsedCommandType")] + + +class ParsedCommand( + RootModel[ + ReadParsedCommand + | ListFilesParsedCommand + | SearchParsedCommand + | UnknownParsedCommand + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + ReadParsedCommand + | ListFilesParsedCommand + | SearchParsedCommand + | UnknownParsedCommand + ) + + +class PatchApplyStatus(Enum): + in_progress = "inProgress" + completed = "completed" + failed = "failed" + declined = "declined" + + +class AddPatchChangeKind(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["add"], Field(title="AddPatchChangeKindType")] + + +class DeletePatchChangeKind(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["delete"], Field(title="DeletePatchChangeKindType")] + + +class UpdatePatchChangeKind(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + move_path: str | None = None + type: Annotated[Literal["update"], Field(title="UpdatePatchChangeKindType")] + + +class PatchChangeKind( + RootModel[AddPatchChangeKind | DeletePatchChangeKind | UpdatePatchChangeKind] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: AddPatchChangeKind | DeletePatchChangeKind | UpdatePatchChangeKind + + +class PermissionProfile(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + file_system: FileSystemPermissions | None = None + macos: MacOsSeatbeltProfileExtensions | None = None + network: NetworkPermissions | None = None + + +class Personality(Enum): + none = "none" + friendly = "friendly" + pragmatic = "pragmatic" + + +class PlanDeltaNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + item_id: Annotated[str, Field(alias="itemId")] + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class PlanType(Enum): + free = "free" + go = "go" + plus = "plus" + pro = "pro" + team = "team" + business = "business" + enterprise = "enterprise" + edu = "edu" + unknown = "unknown" + + +class PluginAuthPolicy(Enum): + on_install = "ON_INSTALL" + on_use = "ON_USE" + + +class PluginInstallParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + marketplace_path: Annotated[AbsolutePathBuf, Field(alias="marketplacePath")] + plugin_name: Annotated[str, Field(alias="pluginName")] + + +class PluginInstallPolicy(Enum): + not_available = "NOT_AVAILABLE" + available = "AVAILABLE" + installed_by_default = "INSTALLED_BY_DEFAULT" + + +class PluginInstallResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + apps_needing_auth: Annotated[list[AppSummary], Field(alias="appsNeedingAuth")] + auth_policy: Annotated[PluginAuthPolicy, Field(alias="authPolicy")] + + +class PluginInterface(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + brand_color: Annotated[str | None, Field(alias="brandColor")] = None + capabilities: list[str] + category: str | None = None + composer_icon: Annotated[AbsolutePathBuf | None, Field(alias="composerIcon")] = None + default_prompt: Annotated[str | None, Field(alias="defaultPrompt")] = None + developer_name: Annotated[str | None, Field(alias="developerName")] = None + display_name: Annotated[str | None, Field(alias="displayName")] = None + logo: AbsolutePathBuf | None = None + long_description: Annotated[str | None, Field(alias="longDescription")] = None + privacy_policy_url: Annotated[str | None, Field(alias="privacyPolicyUrl")] = None + screenshots: list[AbsolutePathBuf] + short_description: Annotated[str | None, Field(alias="shortDescription")] = None + terms_of_service_url: Annotated[str | None, Field(alias="termsOfServiceUrl")] = None + website_url: Annotated[str | None, Field(alias="websiteUrl")] = None + + +class PluginListParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cwds: Annotated[ + list[AbsolutePathBuf] | None, + Field( + description="Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered." + ), + ] = None + force_remote_sync: Annotated[ + bool | None, + Field( + alias="forceRemoteSync", + description="When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", + ), + ] = None + + +class LocalPluginSource(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + path: AbsolutePathBuf + type: Annotated[Literal["local"], Field(title="LocalPluginSourceType")] + + +class PluginSource(RootModel[LocalPluginSource]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: LocalPluginSource + + +class PluginSummary(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + auth_policy: Annotated[PluginAuthPolicy, Field(alias="authPolicy")] + enabled: bool + id: str + install_policy: Annotated[PluginInstallPolicy, Field(alias="installPolicy")] + installed: bool + interface: PluginInterface | None = None + name: str + source: PluginSource + + +class PluginUninstallParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + plugin_id: Annotated[str, Field(alias="pluginId")] + + +class PluginUninstallResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class ProductSurface(Enum): + chatgpt = "chatgpt" + codex = "codex" + api = "api" + atlas = "atlas" + + +class RateLimitWindow(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + resets_at: Annotated[int | None, Field(alias="resetsAt")] = None + used_percent: Annotated[int, Field(alias="usedPercent")] + window_duration_mins: Annotated[int | None, Field(alias="windowDurationMins")] = ( + None + ) + + +class RestrictedReadOnlyAccess(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + include_platform_defaults: Annotated[ + bool | None, Field(alias="includePlatformDefaults") + ] = True + readable_roots: Annotated[ + list[AbsolutePathBuf] | None, Field(alias="readableRoots") + ] = [] + type: Annotated[Literal["restricted"], Field(title="RestrictedReadOnlyAccessType")] + + +class FullAccessReadOnlyAccess(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["fullAccess"], Field(title="FullAccessReadOnlyAccessType")] + + +class ReadOnlyAccess(RootModel[RestrictedReadOnlyAccess | FullAccessReadOnlyAccess]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: RestrictedReadOnlyAccess | FullAccessReadOnlyAccess + + +class RealtimeAudioFrame(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: str + num_channels: Annotated[int, Field(ge=0)] + sample_rate: Annotated[int, Field(ge=0)] + samples_per_channel: Annotated[int | None, Field(ge=0)] = None + + +class SessionUpdated(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + instructions: str | None = None + session_id: str + + +class SessionUpdatedRealtimeEvent(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + session_updated: Annotated[SessionUpdated, Field(alias="SessionUpdated")] + + +class AudioOutRealtimeEvent(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + audio_out: Annotated[RealtimeAudioFrame, Field(alias="AudioOut")] + + +class ConversationItemAddedRealtimeEvent(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + conversation_item_added: Annotated[Any, Field(alias="ConversationItemAdded")] + + +class ConversationItemDone(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item_id: str + + +class ConversationItemDoneRealtimeEvent(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + conversation_item_done: Annotated[ + ConversationItemDone, Field(alias="ConversationItemDone") + ] + + +class ErrorRealtimeEvent(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + error: Annotated[str, Field(alias="Error")] + + +class RealtimeTranscriptDelta(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + + +class RealtimeTranscriptEntry(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + role: str + text: str + + +class ReasoningEffort(Enum): + none = "none" + minimal = "minimal" + low = "low" + medium = "medium" + high = "high" + xhigh = "xhigh" + + +class ReasoningEffortOption(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + reasoning_effort: Annotated[ReasoningEffort, Field(alias="reasoningEffort")] + + +class ReasoningTextReasoningItemContent(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str + type: Annotated[ + Literal["reasoning_text"], Field(title="ReasoningTextReasoningItemContentType") + ] + + +class TextReasoningItemContent(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str + type: Annotated[Literal["text"], Field(title="TextReasoningItemContentType")] + + +class ReasoningItemContent( + RootModel[ReasoningTextReasoningItemContent | TextReasoningItemContent] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ReasoningTextReasoningItemContent | TextReasoningItemContent + + +class SummaryTextReasoningItemReasoningSummary(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str + type: Annotated[ + Literal["summary_text"], + Field(title="SummaryTextReasoningItemReasoningSummaryType"), + ] + + +class ReasoningItemReasoningSummary( + RootModel[SummaryTextReasoningItemReasoningSummary] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: SummaryTextReasoningItemReasoningSummary + + +class ReasoningSummaryValue(Enum): + auto = "auto" + concise = "concise" + detailed = "detailed" + + +class ReasoningSummary(RootModel[ReasoningSummaryValue | Literal["none"]]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + ReasoningSummaryValue | Literal["none"], + Field( + description="A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries" + ), + ] + + +class ReasoningSummaryPartAddedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item_id: Annotated[str, Field(alias="itemId")] + summary_index: Annotated[int, Field(alias="summaryIndex")] + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class ReasoningSummaryTextDeltaNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delta: str + item_id: Annotated[str, Field(alias="itemId")] + summary_index: Annotated[int, Field(alias="summaryIndex")] + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class ReasoningTextDeltaNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + content_index: Annotated[int, Field(alias="contentIndex")] + delta: str + item_id: Annotated[str, Field(alias="itemId")] + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class RemoteSkillSummary(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + id: str + name: str + + +class RequestId(RootModel[str | int]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: str | int + + +class RequestUserInputQuestionOption(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + label: str + + +class ResidencyRequirement(RootModel[Literal["us"]]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Literal["us"] + + +class Resource(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + field_meta: Annotated[Any | None, Field(alias="_meta")] = None + annotations: Any | None = None + description: str | None = None + icons: list | None = None + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + name: str + size: int | None = None + title: str | None = None + uri: str + + +class ResourceTemplate(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + annotations: Any | None = None + description: str | None = None + mime_type: Annotated[str | None, Field(alias="mimeType")] = None + name: str + title: str | None = None + uri_template: Annotated[str, Field(alias="uriTemplate")] + + +class MessageResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + content: list[ContentItem] + end_turn: bool | None = None + id: str | None = None + phase: MessagePhase | None = None + role: str + type: Annotated[Literal["message"], Field(title="MessageResponseItemType")] + + +class ReasoningResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + content: list[ReasoningItemContent] | None = None + encrypted_content: str | None = None + id: str + summary: list[ReasoningItemReasoningSummary] + type: Annotated[Literal["reasoning"], Field(title="ReasoningResponseItemType")] + + +class LocalShellCallResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + action: LocalShellAction + call_id: Annotated[ + str | None, Field(description="Set when using the Responses API.") + ] = None + id: Annotated[ + str | None, + Field( + description="Legacy id field retained for compatibility with older payloads." + ), + ] = None + status: LocalShellStatus + type: Annotated[ + Literal["local_shell_call"], Field(title="LocalShellCallResponseItemType") + ] + + +class FunctionCallResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + arguments: str + call_id: str + id: str | None = None + name: str + namespace: str | None = None + type: Annotated[ + Literal["function_call"], Field(title="FunctionCallResponseItemType") + ] + + +class ToolSearchCallResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + arguments: Any + call_id: str | None = None + execution: str + id: str | None = None + status: str | None = None + type: Annotated[ + Literal["tool_search_call"], Field(title="ToolSearchCallResponseItemType") + ] + + +class CustomToolCallResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: str + id: str | None = None + input: str + name: str + status: str | None = None + type: Annotated[ + Literal["custom_tool_call"], Field(title="CustomToolCallResponseItemType") + ] + + +class ToolSearchOutputResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: str | None = None + execution: str + status: str + tools: list + type: Annotated[ + Literal["tool_search_output"], Field(title="ToolSearchOutputResponseItemType") + ] + + +class ImageGenerationCallResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + result: str + revised_prompt: str | None = None + status: str + type: Annotated[ + Literal["image_generation_call"], + Field(title="ImageGenerationCallResponseItemType"), + ] + + +class GhostSnapshotResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + ghost_commit: GhostCommit + type: Annotated[ + Literal["ghost_snapshot"], Field(title="GhostSnapshotResponseItemType") + ] + + +class CompactionResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + encrypted_content: str + type: Annotated[Literal["compaction"], Field(title="CompactionResponseItemType")] + + +class OtherResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["other"], Field(title="OtherResponseItemType")] + + +class SearchResponsesApiWebSearchAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + queries: list[str] | None = None + query: str | None = None + type: Annotated[ + Literal["search"], Field(title="SearchResponsesApiWebSearchActionType") + ] + + +class OpenPageResponsesApiWebSearchAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["open_page"], Field(title="OpenPageResponsesApiWebSearchActionType") + ] + url: str | None = None + + +class FindInPageResponsesApiWebSearchAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + pattern: str | None = None + type: Annotated[ + Literal["find_in_page"], + Field(title="FindInPageResponsesApiWebSearchActionType"), + ] + url: str | None = None + + +class OtherResponsesApiWebSearchAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["other"], Field(title="OtherResponsesApiWebSearchActionType") + ] + + +class ResponsesApiWebSearchAction( + RootModel[ + SearchResponsesApiWebSearchAction + | OpenPageResponsesApiWebSearchAction + | FindInPageResponsesApiWebSearchAction + | OtherResponsesApiWebSearchAction + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + SearchResponsesApiWebSearchAction + | OpenPageResponsesApiWebSearchAction + | FindInPageResponsesApiWebSearchAction + | OtherResponsesApiWebSearchAction + ) + + +class OkResultOfCallToolResultOrString(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + ok: Annotated[CallToolResult, Field(alias="Ok")] + + +class ErrResultOfCallToolResultOrString(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + err: Annotated[str, Field(alias="Err")] + + +class ResultOfCallToolResultOrString( + RootModel[OkResultOfCallToolResultOrString | ErrResultOfCallToolResultOrString] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: OkResultOfCallToolResultOrString | ErrResultOfCallToolResultOrString + + +class ApprovedExecpolicyAmendment(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + proposed_execpolicy_amendment: list[str] + + +class ApprovedExecpolicyAmendmentReviewDecision(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + approved_execpolicy_amendment: ApprovedExecpolicyAmendment + + +class ReviewDelivery(Enum): + inline = "inline" + detached = "detached" + + +class ReviewLineRange(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + end: Annotated[int, Field(ge=0)] + start: Annotated[int, Field(ge=0)] + + +class UncommittedChangesReviewTarget(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["uncommittedChanges"], Field(title="UncommittedChangesReviewTargetType") + ] + + +class BaseBranchReviewTarget(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + branch: str + type: Annotated[Literal["baseBranch"], Field(title="BaseBranchReviewTargetType")] + + +class CommitReviewTarget(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + sha: str + title: Annotated[ + str | None, + Field( + description="Optional human-readable label (e.g., commit subject) for UIs." + ), + ] = None + type: Annotated[Literal["commit"], Field(title="CommitReviewTargetType")] + + +class CustomReviewTarget(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + instructions: str + type: Annotated[Literal["custom"], Field(title="CustomReviewTargetType")] + + +class ReviewTarget( + RootModel[ + UncommittedChangesReviewTarget + | BaseBranchReviewTarget + | CommitReviewTarget + | CustomReviewTarget + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + UncommittedChangesReviewTarget + | BaseBranchReviewTarget + | CommitReviewTarget + | CustomReviewTarget + ) + + +class SandboxMode(Enum): + read_only = "read-only" + workspace_write = "workspace-write" + danger_full_access = "danger-full-access" + + +class DangerFullAccessSandboxPolicy(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["dangerFullAccess"], Field(title="DangerFullAccessSandboxPolicyType") + ] + + +class ReadOnlySandboxPolicy(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + access: Annotated[ReadOnlyAccess | None, Field()] = {"type": "fullAccess"} + network_access: Annotated[bool | None, Field(alias="networkAccess")] = False + type: Annotated[Literal["readOnly"], Field(title="ReadOnlySandboxPolicyType")] + + +class ExternalSandboxSandboxPolicy(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + network_access: Annotated[NetworkAccess | None, Field(alias="networkAccess")] = ( + "restricted" + ) + type: Annotated[ + Literal["externalSandbox"], Field(title="ExternalSandboxSandboxPolicyType") + ] + + +class WorkspaceWriteSandboxPolicy(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + exclude_slash_tmp: Annotated[bool | None, Field(alias="excludeSlashTmp")] = False + exclude_tmpdir_env_var: Annotated[ + bool | None, Field(alias="excludeTmpdirEnvVar") + ] = False + network_access: Annotated[bool | None, Field(alias="networkAccess")] = False + read_only_access: Annotated[ + ReadOnlyAccess | None, Field(alias="readOnlyAccess") + ] = {"type": "fullAccess"} + type: Annotated[ + Literal["workspaceWrite"], Field(title="WorkspaceWriteSandboxPolicyType") + ] + writable_roots: Annotated[ + list[AbsolutePathBuf] | None, Field(alias="writableRoots") + ] = [] + + +class SandboxPolicy( + RootModel[ + DangerFullAccessSandboxPolicy + | ReadOnlySandboxPolicy + | ExternalSandboxSandboxPolicy + | WorkspaceWriteSandboxPolicy + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + DangerFullAccessSandboxPolicy + | ReadOnlySandboxPolicy + | ExternalSandboxSandboxPolicy + | WorkspaceWriteSandboxPolicy + ) + + +class SandboxWorkspaceWrite(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + exclude_slash_tmp: bool | None = False + exclude_tmpdir_env_var: bool | None = False + network_access: bool | None = False + writable_roots: list[str] | None = [] + + +class ItemAgentMessageDeltaServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["item/agentMessage/delta"], + Field(title="Item/agentMessage/deltaNotificationMethod"), + ] + params: AgentMessageDeltaNotification + + +class ItemPlanDeltaServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["item/plan/delta"], Field(title="Item/plan/deltaNotificationMethod") + ] + params: PlanDeltaNotification + + +class ItemCommandExecutionOutputDeltaServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["item/commandExecution/outputDelta"], + Field(title="Item/commandExecution/outputDeltaNotificationMethod"), + ] + params: CommandExecutionOutputDeltaNotification + + +class ItemFileChangeOutputDeltaServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["item/fileChange/outputDelta"], + Field(title="Item/fileChange/outputDeltaNotificationMethod"), + ] + params: FileChangeOutputDeltaNotification + + +class ItemMcpToolCallProgressServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["item/mcpToolCall/progress"], + Field(title="Item/mcpToolCall/progressNotificationMethod"), + ] + params: McpToolCallProgressNotification + + +class McpServerOauthLoginCompletedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["mcpServer/oauthLogin/completed"], + Field(title="McpServer/oauthLogin/completedNotificationMethod"), + ] + params: McpServerOauthLoginCompletedNotification + + +class ItemReasoningSummaryTextDeltaServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["item/reasoning/summaryTextDelta"], + Field(title="Item/reasoning/summaryTextDeltaNotificationMethod"), + ] + params: ReasoningSummaryTextDeltaNotification + + +class ItemReasoningSummaryPartAddedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["item/reasoning/summaryPartAdded"], + Field(title="Item/reasoning/summaryPartAddedNotificationMethod"), + ] + params: ReasoningSummaryPartAddedNotification + + +class ItemReasoningTextDeltaServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["item/reasoning/textDelta"], + Field(title="Item/reasoning/textDeltaNotificationMethod"), + ] + params: ReasoningTextDeltaNotification + + +class ThreadCompactedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/compacted"], Field(title="Thread/compactedNotificationMethod") + ] + params: ContextCompactedNotification + + +class ModelReroutedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["model/rerouted"], Field(title="Model/reroutedNotificationMethod") + ] + params: ModelReroutedNotification + + +class DeprecationNoticeServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["deprecationNotice"], Field(title="DeprecationNoticeNotificationMethod") + ] + params: DeprecationNoticeNotification + + +class FuzzyFileSearchSessionUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["fuzzyFileSearch/sessionUpdated"], + Field(title="FuzzyFileSearch/sessionUpdatedNotificationMethod"), + ] + params: FuzzyFileSearchSessionUpdatedNotification + + +class FuzzyFileSearchSessionCompletedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["fuzzyFileSearch/sessionCompleted"], + Field(title="FuzzyFileSearch/sessionCompletedNotificationMethod"), + ] + params: FuzzyFileSearchSessionCompletedNotification + + +class AccountLoginCompletedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["account/login/completed"], + Field(title="Account/login/completedNotificationMethod"), + ] + params: AccountLoginCompletedNotification + + +class ServerRequestResolvedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + request_id: Annotated[RequestId, Field(alias="requestId")] + thread_id: Annotated[str, Field(alias="threadId")] + + +class ServiceTier(Enum): + fast = "fast" + flex = "flex" + + +class SessionNetworkProxyRuntime(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + http_addr: str + socks_addr: str + + +class SessionSourceValue(Enum): + cli = "cli" + vscode = "vscode" + exec = "exec" + app_server = "appServer" + unknown = "unknown" + + +class Settings(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + developer_instructions: str | None = None + model: str + reasoning_effort: ReasoningEffort | None = None + + +class SkillErrorInfo(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + message: str + path: str + + +class SkillInterface(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + brand_color: Annotated[str | None, Field(alias="brandColor")] = None + default_prompt: Annotated[str | None, Field(alias="defaultPrompt")] = None + display_name: Annotated[str | None, Field(alias="displayName")] = None + icon_large: Annotated[str | None, Field(alias="iconLarge")] = None + icon_small: Annotated[str | None, Field(alias="iconSmall")] = None + short_description: Annotated[str | None, Field(alias="shortDescription")] = None + + +class SkillScope(Enum): + user = "user" + repo = "repo" + system = "system" + admin = "admin" + + +class SkillToolDependency(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + command: str | None = None + description: str | None = None + transport: str | None = None + type: str + url: str | None = None + value: str + + +class SkillsChangedNotification(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class SkillsConfigWriteParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + enabled: bool + path: str + + +class SkillsConfigWriteResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + effective_enabled: Annotated[bool, Field(alias="effectiveEnabled")] + + +class SkillsListExtraRootsForCwd(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cwd: str + extra_user_roots: Annotated[list[str], Field(alias="extraUserRoots")] + + +class SkillsListParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cwds: Annotated[ + list[str] | None, + Field( + description="When empty, defaults to the current session working directory." + ), + ] = None + force_reload: Annotated[ + bool | None, + Field( + alias="forceReload", + description="When true, bypass the skills cache and re-scan skills from disk.", + ), + ] = None + per_cwd_extra_user_roots: Annotated[ + list[SkillsListExtraRootsForCwd] | None, + Field( + alias="perCwdExtraUserRoots", + description="Optional per-cwd extra roots to scan as user-scoped skills.", + ), + ] = None + + +class SkillsRemoteReadParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + enabled: bool | None = False + hazelnut_scope: Annotated[HazelnutScope | None, Field(alias="hazelnutScope")] = ( + "example" + ) + product_surface: Annotated[ProductSurface | None, Field(alias="productSurface")] = ( + "codex" + ) + + +class SkillsRemoteReadResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[RemoteSkillSummary] + + +class SkillsRemoteWriteParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + hazelnut_id: Annotated[str, Field(alias="hazelnutId")] + + +class SkillsRemoteWriteResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + path: str + + +class StepStatus(Enum): + pending = "pending" + in_progress = "in_progress" + completed = "completed" + + +class SubAgentSourceValue(Enum): + review = "review" + compact = "compact" + memory_consolidation = "memory_consolidation" + + +class OtherSubAgentSource(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + other: str + + +class TerminalInteractionNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item_id: Annotated[str, Field(alias="itemId")] + process_id: Annotated[str, Field(alias="processId")] + stdin: str + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class TextElement(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + byte_range: Annotated[ + ByteRange, + Field( + alias="byteRange", + description="Byte range in the parent `text` buffer that this element occupies.", + ), + ] + placeholder: Annotated[ + str | None, + Field( + description="Optional human-readable placeholder for the element, displayed in the UI." + ), + ] = None + + +class TextPosition(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + column: Annotated[ + int, + Field(description="1-based column number (in Unicode scalar values).", ge=0), + ] + line: Annotated[int, Field(description="1-based line number.", ge=0)] + + +class TextRange(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + end: TextPosition + start: TextPosition + + +class ThreadActiveFlag(Enum): + waiting_on_approval = "waitingOnApproval" + waiting_on_user_input = "waitingOnUserInput" + + +class ThreadArchiveParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadArchiveResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class ThreadArchivedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadClosedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadCompactStartParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadCompactStartResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class ThreadForkParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + approval_policy: Annotated[AskForApproval | None, Field(alias="approvalPolicy")] = ( + None + ) + base_instructions: Annotated[str | None, Field(alias="baseInstructions")] = None + config: dict[str, Any] | None = None + cwd: str | None = None + developer_instructions: Annotated[ + str | None, Field(alias="developerInstructions") + ] = None + ephemeral: bool | None = None + model: Annotated[ + str | None, + Field(description="Configuration overrides for the forked thread, if any."), + ] = None + model_provider: Annotated[str | None, Field(alias="modelProvider")] = None + sandbox: SandboxMode | None = None + service_tier: Annotated[ServiceTier | None, Field(alias="serviceTier")] = None + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadId(RootModel[str]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: str + + +class AgentMessageThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + phase: MessagePhase | None = None + text: str + type: Annotated[Literal["agentMessage"], Field(title="AgentMessageThreadItemType")] + + +class PlanThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + text: str + type: Annotated[Literal["plan"], Field(title="PlanThreadItemType")] + + +class ReasoningThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + content: list[str] | None = [] + id: str + summary: list[str] | None = [] + type: Annotated[Literal["reasoning"], Field(title="ReasoningThreadItemType")] + + +class CommandExecutionThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + aggregated_output: Annotated[ + str | None, + Field( + alias="aggregatedOutput", + description="The command's output, aggregated from stdout and stderr.", + ), + ] = None + command: Annotated[str, Field(description="The command to be executed.")] + command_actions: Annotated[ + list[CommandAction], + Field( + alias="commandActions", + description="A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + ), + ] + cwd: Annotated[str, Field(description="The command's working directory.")] + duration_ms: Annotated[ + int | None, + Field( + alias="durationMs", + description="The duration of the command execution in milliseconds.", + ), + ] = None + exit_code: Annotated[ + int | None, Field(alias="exitCode", description="The command's exit code.") + ] = None + id: str + process_id: Annotated[ + str | None, + Field( + alias="processId", + description="Identifier for the underlying PTY process (when available).", + ), + ] = None + status: CommandExecutionStatus + type: Annotated[ + Literal["commandExecution"], Field(title="CommandExecutionThreadItemType") + ] + + +class McpToolCallThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + arguments: Any + duration_ms: Annotated[ + int | None, + Field( + alias="durationMs", + description="The duration of the MCP tool call in milliseconds.", + ), + ] = None + error: McpToolCallError | None = None + id: str + result: McpToolCallResult | None = None + server: str + status: McpToolCallStatus + tool: str + type: Annotated[Literal["mcpToolCall"], Field(title="McpToolCallThreadItemType")] + + +class DynamicToolCallThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + arguments: Any + content_items: Annotated[ + list[DynamicToolCallOutputContentItem] | None, Field(alias="contentItems") + ] = None + duration_ms: Annotated[ + int | None, + Field( + alias="durationMs", + description="The duration of the dynamic tool call in milliseconds.", + ), + ] = None + id: str + status: DynamicToolCallStatus + success: bool | None = None + tool: str + type: Annotated[ + Literal["dynamicToolCall"], Field(title="DynamicToolCallThreadItemType") + ] + + +class ImageViewThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + path: str + type: Annotated[Literal["imageView"], Field(title="ImageViewThreadItemType")] + + +class ImageGenerationThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + result: str + revised_prompt: Annotated[str | None, Field(alias="revisedPrompt")] = None + status: str + type: Annotated[ + Literal["imageGeneration"], Field(title="ImageGenerationThreadItemType") + ] + + +class EnteredReviewModeThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + review: str + type: Annotated[ + Literal["enteredReviewMode"], Field(title="EnteredReviewModeThreadItemType") + ] + + +class ExitedReviewModeThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + review: str + type: Annotated[ + Literal["exitedReviewMode"], Field(title="ExitedReviewModeThreadItemType") + ] + + +class ContextCompactionThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + type: Annotated[ + Literal["contextCompaction"], Field(title="ContextCompactionThreadItemType") + ] + + +class ThreadLoadedListParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cursor: Annotated[ + str | None, + Field(description="Opaque pagination cursor returned by a previous call."), + ] = None + limit: Annotated[ + int | None, Field(description="Optional page size; defaults to no limit.", ge=0) + ] = None + + +class ThreadLoadedListResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: Annotated[ + list[str], + Field(description="Thread ids for sessions currently loaded in memory."), + ] + next_cursor: Annotated[ + str | None, + Field( + alias="nextCursor", + description="Opaque cursor to pass to the next call to continue after the last item. if None, there are no more items to return.", + ), + ] = None + + +class ThreadMetadataGitInfoUpdateParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + branch: Annotated[ + str | None, + Field( + description="Omit to leave the stored branch unchanged, set to `null` to clear it, or provide a non-empty string to replace it." + ), + ] = None + origin_url: Annotated[ + str | None, + Field( + alias="originUrl", + description="Omit to leave the stored origin URL unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + ), + ] = None + sha: Annotated[ + str | None, + Field( + description="Omit to leave the stored commit unchanged, set to `null` to clear it, or provide a non-empty string to replace it." + ), + ] = None + + +class ThreadMetadataUpdateParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + git_info: Annotated[ + ThreadMetadataGitInfoUpdateParams | None, + Field( + alias="gitInfo", + description="Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value.", + ), + ] = None + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadNameUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + thread_name: Annotated[str | None, Field(alias="threadName")] = None + + +class ThreadReadParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + include_turns: Annotated[ + bool | None, + Field( + alias="includeTurns", + description="When true, include turns and their items from rollout history.", + ), + ] = False + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadRealtimeAudioChunk(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: str + num_channels: Annotated[int, Field(alias="numChannels", ge=0)] + sample_rate: Annotated[int, Field(alias="sampleRate", ge=0)] + samples_per_channel: Annotated[ + int | None, Field(alias="samplesPerChannel", ge=0) + ] = None + + +class ThreadRealtimeClosedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + reason: str | None = None + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadRealtimeErrorNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + message: str + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadRealtimeItemAddedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item: Any + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadRealtimeOutputAudioDeltaNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + audio: ThreadRealtimeAudioChunk + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadRealtimeStartedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + session_id: Annotated[str | None, Field(alias="sessionId")] = None + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadResumeParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + approval_policy: Annotated[AskForApproval | None, Field(alias="approvalPolicy")] = ( + None + ) + base_instructions: Annotated[str | None, Field(alias="baseInstructions")] = None + config: dict[str, Any] | None = None + cwd: str | None = None + developer_instructions: Annotated[ + str | None, Field(alias="developerInstructions") + ] = None + model: Annotated[ + str | None, + Field(description="Configuration overrides for the resumed thread, if any."), + ] = None + model_provider: Annotated[str | None, Field(alias="modelProvider")] = None + personality: Personality | None = None + sandbox: SandboxMode | None = None + service_tier: Annotated[ServiceTier | None, Field(alias="serviceTier")] = None + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadRollbackParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + num_turns: Annotated[ + int, + Field( + alias="numTurns", + description="The number of turns to drop from the end of the thread. Must be >= 1.\n\nThis only modifies the thread's history and does not revert local file changes that have been made by the agent. Clients are responsible for reverting these changes.", + ge=0, + ), + ] + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadSetNameParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + name: str + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadSetNameResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class ThreadSortKey(Enum): + created_at = "created_at" + updated_at = "updated_at" + + +class ThreadSourceKind(Enum): + cli = "cli" + vscode = "vscode" + exec = "exec" + app_server = "appServer" + sub_agent = "subAgent" + sub_agent_review = "subAgentReview" + sub_agent_compact = "subAgentCompact" + sub_agent_thread_spawn = "subAgentThreadSpawn" + sub_agent_other = "subAgentOther" + unknown = "unknown" + + +class ThreadStartParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + approval_policy: Annotated[AskForApproval | None, Field(alias="approvalPolicy")] = ( + None + ) + base_instructions: Annotated[str | None, Field(alias="baseInstructions")] = None + config: dict[str, Any] | None = None + cwd: str | None = None + developer_instructions: Annotated[ + str | None, Field(alias="developerInstructions") + ] = None + ephemeral: bool | None = None + model: str | None = None + model_provider: Annotated[str | None, Field(alias="modelProvider")] = None + personality: Personality | None = None + sandbox: SandboxMode | None = None + service_name: Annotated[str | None, Field(alias="serviceName")] = None + service_tier: Annotated[ServiceTier | None, Field(alias="serviceTier")] = None + + +class NotLoadedThreadStatus(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["notLoaded"], Field(title="NotLoadedThreadStatusType")] + + +class IdleThreadStatus(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["idle"], Field(title="IdleThreadStatusType")] + + +class SystemErrorThreadStatus(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["systemError"], Field(title="SystemErrorThreadStatusType")] + + +class ActiveThreadStatus(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + active_flags: Annotated[list[ThreadActiveFlag], Field(alias="activeFlags")] + type: Annotated[Literal["active"], Field(title="ActiveThreadStatusType")] + + +class ThreadStatus( + RootModel[ + NotLoadedThreadStatus + | IdleThreadStatus + | SystemErrorThreadStatus + | ActiveThreadStatus + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + NotLoadedThreadStatus + | IdleThreadStatus + | SystemErrorThreadStatus + | ActiveThreadStatus + ) + + +class ThreadStatusChangedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + status: ThreadStatus + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadUnarchiveParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadUnarchivedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadUnsubscribeParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadUnsubscribeStatus(Enum): + not_loaded = "notLoaded" + not_subscribed = "notSubscribed" + unsubscribed = "unsubscribed" + + +class TokenUsage(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cached_input_tokens: int + input_tokens: int + output_tokens: int + reasoning_output_tokens: int + total_tokens: int + + +class TokenUsageBreakdown(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cached_input_tokens: Annotated[int, Field(alias="cachedInputTokens")] + input_tokens: Annotated[int, Field(alias="inputTokens")] + output_tokens: Annotated[int, Field(alias="outputTokens")] + reasoning_output_tokens: Annotated[int, Field(alias="reasoningOutputTokens")] + total_tokens: Annotated[int, Field(alias="totalTokens")] + + +class TokenUsageInfo(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + last_token_usage: TokenUsage + model_context_window: int | None = None + total_token_usage: TokenUsage + + +class Tool(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + field_meta: Annotated[Any | None, Field(alias="_meta")] = None + annotations: Any | None = None + description: str | None = None + icons: list | None = None + input_schema: Annotated[Any, Field(alias="inputSchema")] + name: str + output_schema: Annotated[Any | None, Field(alias="outputSchema")] = None + title: str | None = None + + +class TurnAbortReason(Enum): + interrupted = "interrupted" + replaced = "replaced" + review_ended = "review_ended" + + +class TurnDiffUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + diff: str + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class TurnError(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + additional_details: Annotated[str | None, Field(alias="additionalDetails")] = None + codex_error_info: Annotated[ + CodexErrorInfo | None, Field(alias="codexErrorInfo") + ] = None + message: str + + +class TurnInterruptParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class TurnInterruptResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class AgentMessageTurnItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + content: list[AgentMessageContent] + id: str + phase: Annotated[ + MessagePhase | None, + Field( + description="Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." + ), + ] = None + type: Annotated[Literal["AgentMessage"], Field(title="AgentMessageTurnItemType")] + + +class PlanTurnItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + text: str + type: Annotated[Literal["Plan"], Field(title="PlanTurnItemType")] + + +class ReasoningTurnItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + raw_content: list[str] | None = [] + summary_text: list[str] + type: Annotated[Literal["Reasoning"], Field(title="ReasoningTurnItemType")] + + +class WebSearchTurnItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + action: ResponsesApiWebSearchAction + id: str + query: str + type: Annotated[Literal["WebSearch"], Field(title="WebSearchTurnItemType")] + + +class ImageGenerationTurnItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + result: str + revised_prompt: str | None = None + saved_path: str | None = None + status: str + type: Annotated[ + Literal["ImageGeneration"], Field(title="ImageGenerationTurnItemType") + ] + + +class ContextCompactionTurnItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + type: Annotated[ + Literal["ContextCompaction"], Field(title="ContextCompactionTurnItemType") + ] + + +class TurnPlanStepStatus(Enum): + pending = "pending" + in_progress = "inProgress" + completed = "completed" + + +class TurnStatus(Enum): + completed = "completed" + interrupted = "interrupted" + failed = "failed" + in_progress = "inProgress" + + +class TurnSteerResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + turn_id: Annotated[str, Field(alias="turnId")] + + +class TextUserInput(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str + text_elements: Annotated[ + list[TextElement] | None, + Field( + description="UI-defined spans within `text` used to render or persist special elements." + ), + ] = [] + type: Annotated[Literal["text"], Field(title="TextUserInputType")] + + +class ImageUserInput(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["image"], Field(title="ImageUserInputType")] + url: str + + +class LocalImageUserInput(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + path: str + type: Annotated[Literal["localImage"], Field(title="LocalImageUserInputType")] + + +class SkillUserInput(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + name: str + path: str + type: Annotated[Literal["skill"], Field(title="SkillUserInputType")] + + +class MentionUserInput(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + name: str + path: str + type: Annotated[Literal["mention"], Field(title="MentionUserInputType")] + + +class UserInput( + RootModel[ + TextUserInput + | ImageUserInput + | LocalImageUserInput + | SkillUserInput + | MentionUserInput + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + TextUserInput + | ImageUserInput + | LocalImageUserInput + | SkillUserInput + | MentionUserInput + ) + + +class Verbosity(Enum): + low = "low" + medium = "medium" + high = "high" + + +class SearchWebSearchAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + queries: list[str] | None = None + query: str | None = None + type: Annotated[Literal["search"], Field(title="SearchWebSearchActionType")] + + +class OpenPageWebSearchAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["openPage"], Field(title="OpenPageWebSearchActionType")] + url: str | None = None + + +class FindInPageWebSearchAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + pattern: str | None = None + type: Annotated[Literal["findInPage"], Field(title="FindInPageWebSearchActionType")] + url: str | None = None + + +class OtherWebSearchAction(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["other"], Field(title="OtherWebSearchActionType")] + + +class WebSearchAction( + RootModel[ + SearchWebSearchAction + | OpenPageWebSearchAction + | FindInPageWebSearchAction + | OtherWebSearchAction + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + SearchWebSearchAction + | OpenPageWebSearchAction + | FindInPageWebSearchAction + | OtherWebSearchAction + ) + + +class WebSearchContextSize(Enum): + low = "low" + medium = "medium" + high = "high" + + +class WebSearchLocation(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + city: str | None = None + country: str | None = None + region: str | None = None + timezone: str | None = None + + +class WebSearchMode(Enum): + disabled = "disabled" + cached = "cached" + live = "live" + + +class WebSearchToolConfig(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + allowed_domains: list[str] | None = None + context_size: WebSearchContextSize | None = None + location: WebSearchLocation | None = None + + +class WindowsSandboxSetupMode(Enum): + elevated = "elevated" + unelevated = "unelevated" + + +class WindowsSandboxSetupStartParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cwd: AbsolutePathBuf | None = None + mode: WindowsSandboxSetupMode + + +class WindowsSandboxSetupStartResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + started: bool + + +class WindowsWorldWritableWarningNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + extra_count: Annotated[int, Field(alias="extraCount", ge=0)] + failed_scan: Annotated[bool, Field(alias="failedScan")] + sample_paths: Annotated[list[str], Field(alias="samplePaths")] + + +class WriteStatus(Enum): + ok = "ok" + ok_overridden = "okOverridden" + + +class ChatgptAccount(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + email: str + plan_type: Annotated[PlanType, Field(alias="planType")] + type: Annotated[Literal["chatgpt"], Field(title="ChatgptAccountType")] + + +class Account(RootModel[ApiKeyAccount | ChatgptAccount]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ApiKeyAccount | ChatgptAccount + + +class AccountUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + auth_mode: Annotated[AuthMode | None, Field(alias="authMode")] = None + plan_type: Annotated[PlanType | None, Field(alias="planType")] = None + + +class AppConfig(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + default_tools_approval_mode: AppToolApproval | None = None + default_tools_enabled: bool | None = None + destructive_enabled: bool | None = None + enabled: bool | None = True + open_world_enabled: bool | None = None + tools: AppToolsConfig | None = None + + +class AppMetadata(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + categories: list[str] | None = None + developer: str | None = None + first_party_requires_install: Annotated[ + bool | None, Field(alias="firstPartyRequiresInstall") + ] = None + first_party_type: Annotated[str | None, Field(alias="firstPartyType")] = None + review: AppReview | None = None + screenshots: list[AppScreenshot] | None = None + seo_description: Annotated[str | None, Field(alias="seoDescription")] = None + show_in_composer_when_unlinked: Annotated[ + bool | None, Field(alias="showInComposerWhenUnlinked") + ] = None + sub_categories: Annotated[list[str] | None, Field(alias="subCategories")] = None + version: str | None = None + version_id: Annotated[str | None, Field(alias="versionId")] = None + version_notes: Annotated[str | None, Field(alias="versionNotes")] = None + + +class AppsConfig(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + field_default: Annotated[AppsDefaultConfig | None, Field(alias="_default")] = None + + +class CancelLoginAccountResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + status: CancelLoginAccountStatus + + +class InitializeRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["initialize"], Field(title="InitializeRequestMethod")] + params: InitializeParams + + +class ThreadStartRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/start"], Field(title="Thread/startRequestMethod")] + params: ThreadStartParams + + +class ThreadResumeRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["thread/resume"], Field(title="Thread/resumeRequestMethod") + ] + params: ThreadResumeParams + + +class ThreadForkRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/fork"], Field(title="Thread/forkRequestMethod")] + params: ThreadForkParams + + +class ThreadArchiveRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["thread/archive"], Field(title="Thread/archiveRequestMethod") + ] + params: ThreadArchiveParams + + +class ThreadUnsubscribeRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["thread/unsubscribe"], Field(title="Thread/unsubscribeRequestMethod") + ] + params: ThreadUnsubscribeParams + + +class ThreadNameSetRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["thread/name/set"], Field(title="Thread/name/setRequestMethod") + ] + params: ThreadSetNameParams + + +class ThreadMetadataUpdateRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["thread/metadata/update"], + Field(title="Thread/metadata/updateRequestMethod"), + ] + params: ThreadMetadataUpdateParams + + +class ThreadUnarchiveRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["thread/unarchive"], Field(title="Thread/unarchiveRequestMethod") + ] + params: ThreadUnarchiveParams + + +class ThreadCompactStartRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["thread/compact/start"], + Field(title="Thread/compact/startRequestMethod"), + ] + params: ThreadCompactStartParams + + +class ThreadRollbackRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["thread/rollback"], Field(title="Thread/rollbackRequestMethod") + ] + params: ThreadRollbackParams + + +class ThreadLoadedListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["thread/loaded/list"], Field(title="Thread/loaded/listRequestMethod") + ] + params: ThreadLoadedListParams + + +class ThreadReadRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/read"], Field(title="Thread/readRequestMethod")] + params: ThreadReadParams + + +class SkillsListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["skills/list"], Field(title="Skills/listRequestMethod")] + params: SkillsListParams + + +class PluginListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["plugin/list"], Field(title="Plugin/listRequestMethod")] + params: PluginListParams + + +class SkillsRemoteListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["skills/remote/list"], Field(title="Skills/remote/listRequestMethod") + ] + params: SkillsRemoteReadParams + + +class SkillsRemoteExportRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["skills/remote/export"], + Field(title="Skills/remote/exportRequestMethod"), + ] + params: SkillsRemoteWriteParams + + +class AppListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["app/list"], Field(title="App/listRequestMethod")] + params: AppsListParams + + +class SkillsConfigWriteRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["skills/config/write"], Field(title="Skills/config/writeRequestMethod") + ] + params: SkillsConfigWriteParams + + +class PluginInstallRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["plugin/install"], Field(title="Plugin/installRequestMethod") + ] + params: PluginInstallParams + + +class PluginUninstallRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["plugin/uninstall"], Field(title="Plugin/uninstallRequestMethod") + ] + params: PluginUninstallParams + + +class TurnInterruptRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["turn/interrupt"], Field(title="Turn/interruptRequestMethod") + ] + params: TurnInterruptParams + + +class ModelListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["model/list"], Field(title="Model/listRequestMethod")] + params: ModelListParams + + +class ExperimentalFeatureListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["experimentalFeature/list"], + Field(title="ExperimentalFeature/listRequestMethod"), + ] + params: ExperimentalFeatureListParams + + +class McpServerOauthLoginRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["mcpServer/oauth/login"], + Field(title="McpServer/oauth/loginRequestMethod"), + ] + params: McpServerOauthLoginParams + + +class ConfigMcpServerReloadRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["config/mcpServer/reload"], + Field(title="Config/mcpServer/reloadRequestMethod"), + ] + params: None = None + + +class McpServerStatusListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["mcpServerStatus/list"], + Field(title="McpServerStatus/listRequestMethod"), + ] + params: ListMcpServerStatusParams + + +class WindowsSandboxSetupStartRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["windowsSandbox/setupStart"], + Field(title="WindowsSandbox/setupStartRequestMethod"), + ] + params: WindowsSandboxSetupStartParams + + +class AccountLoginStartRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["account/login/start"], Field(title="Account/login/startRequestMethod") + ] + params: LoginAccountParams + + +class AccountLoginCancelRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["account/login/cancel"], + Field(title="Account/login/cancelRequestMethod"), + ] + params: CancelLoginAccountParams + + +class AccountLogoutRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["account/logout"], Field(title="Account/logoutRequestMethod") + ] + params: None = None + + +class AccountRateLimitsReadRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["account/rateLimits/read"], + Field(title="Account/rateLimits/readRequestMethod"), + ] + params: None = None + + +class FeedbackUploadRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["feedback/upload"], Field(title="Feedback/uploadRequestMethod") + ] + params: FeedbackUploadParams + + +class CommandExecWriteRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["command/exec/write"], Field(title="Command/exec/writeRequestMethod") + ] + params: CommandExecWriteParams + + +class CommandExecTerminateRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["command/exec/terminate"], + Field(title="Command/exec/terminateRequestMethod"), + ] + params: CommandExecTerminateParams + + +class ConfigReadRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["config/read"], Field(title="Config/readRequestMethod")] + params: ConfigReadParams + + +class ExternalAgentConfigDetectRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["externalAgentConfig/detect"], + Field(title="ExternalAgentConfig/detectRequestMethod"), + ] + params: ExternalAgentConfigDetectParams + + +class ConfigRequirementsReadRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["configRequirements/read"], + Field(title="ConfigRequirements/readRequestMethod"), + ] + params: None = None + + +class AccountReadRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["account/read"], Field(title="Account/readRequestMethod")] + params: GetAccountParams + + +class FuzzyFileSearchRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["fuzzyFileSearch"], Field(title="FuzzyFileSearchRequestMethod") + ] + params: FuzzyFileSearchParams + + +class CollabAgentRef(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + agent_nickname: Annotated[ + str | None, + Field( + description="Optional nickname assigned to an AgentControl-spawned sub-agent." + ), + ] = None + agent_role: Annotated[ + str | None, + Field( + description="Optional role (agent_role) assigned to an AgentControl-spawned sub-agent." + ), + ] = None + thread_id: Annotated[ + ThreadId, Field(description="Thread ID of the receiver/new agent.") + ] + + +class CollabAgentState(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + message: str | None = None + status: CollabAgentStatus + + +class CollabAgentStatusEntry(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + agent_nickname: Annotated[ + str | None, + Field( + description="Optional nickname assigned to an AgentControl-spawned sub-agent." + ), + ] = None + agent_role: Annotated[ + str | None, + Field( + description="Optional role (agent_role) assigned to an AgentControl-spawned sub-agent." + ), + ] = None + status: Annotated[AgentStatus, Field(description="Last known status of the agent.")] + thread_id: Annotated[ + ThreadId, Field(description="Thread ID of the receiver/new agent.") + ] + + +class CollaborationMode(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + mode: ModeKind + settings: Settings + + +class CollaborationModeMask(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + mode: ModeKind | None = None + model: str | None = None + name: str + reasoning_effort: ReasoningEffort | None = None + + +class CommandExecOutputDeltaNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cap_reached: Annotated[ + bool, + Field( + alias="capReached", + description="`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", + ), + ] + delta_base64: Annotated[ + str, Field(alias="deltaBase64", description="Base64-encoded output bytes.") + ] + process_id: Annotated[ + str, + Field( + alias="processId", + description="Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + ), + ] + stream: Annotated[ + CommandExecOutputStream, Field(description="Output stream for this chunk.") + ] + + +class CommandExecParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + command: Annotated[ + list[str], Field(description="Command argv vector. Empty arrays are rejected.") + ] + cwd: Annotated[ + str | None, + Field(description="Optional working directory. Defaults to the server cwd."), + ] = None + disable_output_cap: Annotated[ + bool | None, + Field( + alias="disableOutputCap", + description="Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", + ), + ] = None + disable_timeout: Annotated[ + bool | None, + Field( + alias="disableTimeout", + description="Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", + ), + ] = None + env: Annotated[ + dict[str, Any] | None, + Field( + description="Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable." + ), + ] = None + output_bytes_cap: Annotated[ + int | None, + Field( + alias="outputBytesCap", + description="Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", + ge=0, + ), + ] = None + process_id: Annotated[ + str | None, + Field( + alias="processId", + description="Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", + ), + ] = None + sandbox_policy: Annotated[ + SandboxPolicy | None, + Field( + alias="sandboxPolicy", + description="Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted.", + ), + ] = None + size: Annotated[ + CommandExecTerminalSize | None, + Field( + description="Optional initial PTY size in character cells. Only valid when `tty` is true." + ), + ] = None + stream_stdin: Annotated[ + bool | None, + Field( + alias="streamStdin", + description="Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", + ), + ] = None + stream_stdout_stderr: Annotated[ + bool | None, + Field( + alias="streamStdoutStderr", + description="Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", + ), + ] = None + timeout_ms: Annotated[ + int | None, + Field( + alias="timeoutMs", + description="Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", + ), + ] = None + tty: Annotated[ + bool | None, + Field( + description="Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`." + ), + ] = None + + +class CommandExecResizeParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + process_id: Annotated[ + str, + Field( + alias="processId", + description="Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + ), + ] + size: Annotated[ + CommandExecTerminalSize, Field(description="New PTY size in character cells.") + ] + + +class ConfigEdit(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + key_path: Annotated[str, Field(alias="keyPath")] + merge_strategy: Annotated[MergeStrategy, Field(alias="mergeStrategy")] + value: Any + + +class ConfigLayer(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + config: Any + disabled_reason: Annotated[str | None, Field(alias="disabledReason")] = None + name: ConfigLayerSource + version: str + + +class ConfigLayerMetadata(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + name: ConfigLayerSource + version: str + + +class ConfigRequirements(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + allowed_approval_policies: Annotated[ + list[AskForApproval] | None, Field(alias="allowedApprovalPolicies") + ] = None + allowed_sandbox_modes: Annotated[ + list[SandboxMode] | None, Field(alias="allowedSandboxModes") + ] = None + allowed_web_search_modes: Annotated[ + list[WebSearchMode] | None, Field(alias="allowedWebSearchModes") + ] = None + enforce_residency: Annotated[ + ResidencyRequirement | None, Field(alias="enforceResidency") + ] = None + feature_requirements: Annotated[ + dict[str, Any] | None, Field(alias="featureRequirements") + ] = None + + +class ConfigRequirementsReadResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + requirements: Annotated[ + ConfigRequirements | None, + Field( + description="Null if no requirements are configured (e.g. no requirements.toml/MDM entries)." + ), + ] = None + + +class ConfigValueWriteParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + expected_version: Annotated[str | None, Field(alias="expectedVersion")] = None + file_path: Annotated[ + str | None, + Field( + alias="filePath", + description="Path to the config file to write; defaults to the user's `config.toml` when omitted.", + ), + ] = None + key_path: Annotated[str, Field(alias="keyPath")] + merge_strategy: Annotated[MergeStrategy, Field(alias="mergeStrategy")] + value: Any + + +class ConfigWarningNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + details: Annotated[ + str | None, Field(description="Optional extra guidance or error details.") + ] = None + path: Annotated[ + str | None, + Field( + description="Optional path to the config file that triggered the warning." + ), + ] = None + range: Annotated[ + TextRange | None, + Field( + description="Optional range for the error location inside the config file." + ), + ] = None + summary: Annotated[str, Field(description="Concise summary of the warning.")] + + +class ErrorNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + error: TurnError + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + will_retry: Annotated[bool, Field(alias="willRetry")] + + +class ModelRerouteEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + from_model: str + reason: ModelRerouteReason + to_model: str + type: Annotated[Literal["model_reroute"], Field(title="ModelRerouteEventMsgType")] + + +class TaskStartedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + collaboration_mode_kind: ModeKind | None = "default" + model_context_window: int | None = None + turn_id: str + type: Annotated[Literal["task_started"], Field(title="TaskStartedEventMsgType")] + + +class AgentMessageEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + message: str + phase: MessagePhase | None = None + type: Annotated[Literal["agent_message"], Field(title="AgentMessageEventMsgType")] + + +class UserMessageEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + images: Annotated[ + list[str] | None, + Field( + description="Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model." + ), + ] = None + local_images: Annotated[ + list[str] | None, + Field( + description="Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs." + ), + ] = [] + message: str + text_elements: Annotated[ + list[TextElement] | None, + Field( + description="UI-defined spans within `message` used to render or persist special elements." + ), + ] = [] + type: Annotated[Literal["user_message"], Field(title="UserMessageEventMsgType")] + + +class ThreadNameUpdatedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: ThreadId + thread_name: str | None = None + type: Annotated[ + Literal["thread_name_updated"], Field(title="ThreadNameUpdatedEventMsgType") + ] + + +class McpStartupUpdateEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + server: Annotated[str, Field(description="Server name being started.")] + status: Annotated[McpStartupStatus, Field(description="Current startup status.")] + type: Annotated[ + Literal["mcp_startup_update"], Field(title="McpStartupUpdateEventMsgType") + ] + + +class McpStartupCompleteEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cancelled: list[str] + failed: list[McpStartupFailure] + ready: list[str] + type: Annotated[ + Literal["mcp_startup_complete"], Field(title="McpStartupCompleteEventMsgType") + ] + + +class McpToolCallBeginEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[ + str, + Field( + description="Identifier so this can be paired with the McpToolCallEnd event." + ), + ] + invocation: McpInvocation + type: Annotated[ + Literal["mcp_tool_call_begin"], Field(title="McpToolCallBeginEventMsgType") + ] + + +class McpToolCallEndEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[ + str, + Field( + description="Identifier for the corresponding McpToolCallBegin that finished." + ), + ] + duration: Duration + invocation: McpInvocation + result: Annotated[ + ResultOfCallToolResultOrString, + Field(description="Result of the tool call. Note this could be an error."), + ] + type: Annotated[ + Literal["mcp_tool_call_end"], Field(title="McpToolCallEndEventMsgType") + ] + + +class WebSearchEndEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + action: ResponsesApiWebSearchAction + call_id: str + query: str + type: Annotated[Literal["web_search_end"], Field(title="WebSearchEndEventMsgType")] + + +class ExecCommandBeginEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[ + str, + Field( + description="Identifier so this can be paired with the ExecCommandEnd event." + ), + ] + command: Annotated[list[str], Field(description="The command to be executed.")] + cwd: Annotated[ + str, + Field( + description="The command's working directory if not the default cwd for the agent." + ), + ] + interaction_input: Annotated[ + str | None, + Field( + description="Raw input sent to a unified exec session (if this is an interaction event)." + ), + ] = None + parsed_cmd: list[ParsedCommand] + process_id: Annotated[ + str | None, + Field( + description="Identifier for the underlying PTY process (when available)." + ), + ] = None + source: Annotated[ + ExecCommandSource | None, + Field( + description="Where the command originated. Defaults to Agent for backward compatibility." + ), + ] = "agent" + turn_id: Annotated[str, Field(description="Turn ID that this command belongs to.")] + type: Annotated[ + Literal["exec_command_begin"], Field(title="ExecCommandBeginEventMsgType") + ] + + +class ExecCommandOutputDeltaEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[ + str, + Field( + description="Identifier for the ExecCommandBegin that produced this chunk." + ), + ] + chunk: Annotated[ + str, Field(description="Raw bytes from the stream (may not be valid UTF-8).") + ] + stream: Annotated[ + ExecOutputStream, Field(description="Which stream produced this chunk.") + ] + type: Annotated[ + Literal["exec_command_output_delta"], + Field(title="ExecCommandOutputDeltaEventMsgType"), + ] + + +class ExecCommandEndEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + aggregated_output: Annotated[ + str | None, Field(description="Captured aggregated output") + ] = "" + call_id: Annotated[ + str, Field(description="Identifier for the ExecCommandBegin that finished.") + ] + command: Annotated[list[str], Field(description="The command that was executed.")] + cwd: Annotated[ + str, + Field( + description="The command's working directory if not the default cwd for the agent." + ), + ] + duration: Annotated[ + Duration, Field(description="The duration of the command execution.") + ] + exit_code: Annotated[int, Field(description="The command's exit code.")] + formatted_output: Annotated[ + str, + Field(description="Formatted output from the command, as seen by the model."), + ] + interaction_input: Annotated[ + str | None, + Field( + description="Raw input sent to a unified exec session (if this is an interaction event)." + ), + ] = None + parsed_cmd: list[ParsedCommand] + process_id: Annotated[ + str | None, + Field( + description="Identifier for the underlying PTY process (when available)." + ), + ] = None + source: Annotated[ + ExecCommandSource | None, + Field( + description="Where the command originated. Defaults to Agent for backward compatibility." + ), + ] = "agent" + status: Annotated[ + ExecCommandStatus, + Field(description="Completion status for this command execution."), + ] + stderr: Annotated[str, Field(description="Captured stderr")] + stdout: Annotated[str, Field(description="Captured stdout")] + turn_id: Annotated[str, Field(description="Turn ID that this command belongs to.")] + type: Annotated[ + Literal["exec_command_end"], Field(title="ExecCommandEndEventMsgType") + ] + + +class RequestPermissionsEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[ + str, + Field( + description="Responses API call id for the associated tool call, if available." + ), + ] + permissions: PermissionProfile + reason: str | None = None + turn_id: Annotated[ + str | None, + Field( + description="Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility." + ), + ] = "" + type: Annotated[ + Literal["request_permissions"], Field(title="RequestPermissionsEventMsgType") + ] + + +class ElicitationRequestEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + request: ElicitationRequest + server_name: str + turn_id: Annotated[ + str | None, + Field(description="Turn ID that this elicitation belongs to, when known."), + ] = None + type: Annotated[ + Literal["elicitation_request"], Field(title="ElicitationRequestEventMsgType") + ] + + +class ApplyPatchApprovalRequestEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[ + str, + Field( + description="Responses API call id for the associated patch apply call, if available." + ), + ] + changes: dict[str, FileChange] + grant_root: Annotated[ + str | None, + Field( + description="When set, the agent is asking the user to allow writes under this root for the remainder of the session." + ), + ] = None + reason: Annotated[ + str | None, + Field( + description="Optional explanatory reason (e.g. request for extra write access)." + ), + ] = None + turn_id: Annotated[ + str | None, + Field( + description="Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders." + ), + ] = "" + type: Annotated[ + Literal["apply_patch_approval_request"], + Field(title="ApplyPatchApprovalRequestEventMsgType"), + ] + + +class PatchApplyBeginEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + auto_approved: Annotated[ + bool, + Field( + description="If true, there was no ApplyPatchApprovalRequest for this patch." + ), + ] + call_id: Annotated[ + str, + Field( + description="Identifier so this can be paired with the PatchApplyEnd event." + ), + ] + changes: Annotated[ + dict[str, FileChange], Field(description="The changes to be applied.") + ] + turn_id: Annotated[ + str | None, + Field( + description="Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility." + ), + ] = "" + type: Annotated[ + Literal["patch_apply_begin"], Field(title="PatchApplyBeginEventMsgType") + ] + + +class PatchApplyEndEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[ + str, Field(description="Identifier for the PatchApplyBegin that finished.") + ] + changes: Annotated[ + dict[str, FileChange] | None, + Field( + description="The changes that were applied (mirrors PatchApplyBeginEvent::changes)." + ), + ] = {} + status: Annotated[ + PatchApplyStatus, + Field(description="Completion status for this patch application."), + ] + stderr: Annotated[ + str, Field(description="Captured stderr (parser errors, IO failures, etc.).") + ] + stdout: Annotated[ + str, Field(description="Captured stdout (summary printed by apply_patch).") + ] + success: Annotated[ + bool, Field(description="Whether the patch was applied successfully.") + ] + turn_id: Annotated[ + str | None, + Field( + description="Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility." + ), + ] = "" + type: Annotated[ + Literal["patch_apply_end"], Field(title="PatchApplyEndEventMsgType") + ] + + +class GetHistoryEntryResponseEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + entry: Annotated[ + HistoryEntry | None, + Field( + description="The entry at the requested offset, if available and parseable." + ), + ] = None + log_id: Annotated[int, Field(ge=0)] + offset: Annotated[int, Field(ge=0)] + type: Annotated[ + Literal["get_history_entry_response"], + Field(title="GetHistoryEntryResponseEventMsgType"), + ] + + +class McpListToolsResponseEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + auth_statuses: Annotated[ + dict[str, McpAuthStatus], + Field(description="Authentication status for each configured MCP server."), + ] + resource_templates: Annotated[ + dict[str, list[ResourceTemplate]], + Field(description="Known resource templates grouped by server name."), + ] + resources: Annotated[ + dict[str, list[Resource]], + Field(description="Known resources grouped by server name."), + ] + tools: Annotated[ + dict[str, Tool], + Field(description="Fully qualified tool name -> tool definition."), + ] + type: Annotated[ + Literal["mcp_list_tools_response"], + Field(title="McpListToolsResponseEventMsgType"), + ] + + +class ListRemoteSkillsResponseEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + skills: list[RemoteSkillSummary] + type: Annotated[ + Literal["list_remote_skills_response"], + Field(title="ListRemoteSkillsResponseEventMsgType"), + ] + + +class TurnAbortedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + reason: TurnAbortReason + turn_id: str | None = None + type: Annotated[Literal["turn_aborted"], Field(title="TurnAbortedEventMsgType")] + + +class EnteredReviewModeEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + target: ReviewTarget + type: Annotated[ + Literal["entered_review_mode"], Field(title="EnteredReviewModeEventMsgType") + ] + user_facing_hint: str | None = None + + +class CollabAgentSpawnBeginEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[str, Field(description="Identifier for the collab tool call.")] + model: str + prompt: Annotated[ + str, + Field( + description="Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning." + ), + ] + reasoning_effort: ReasoningEffort + sender_thread_id: Annotated[ThreadId, Field(description="Thread ID of the sender.")] + type: Annotated[ + Literal["collab_agent_spawn_begin"], + Field(title="CollabAgentSpawnBeginEventMsgType"), + ] + + +class CollabAgentSpawnEndEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[str, Field(description="Identifier for the collab tool call.")] + model: Annotated[str, Field(description="Model requested for the spawned agent.")] + new_agent_nickname: Annotated[ + str | None, Field(description="Optional nickname assigned to the new agent.") + ] = None + new_agent_role: Annotated[ + str | None, Field(description="Optional role assigned to the new agent.") + ] = None + new_thread_id: Annotated[ + ThreadId | None, + Field(description="Thread ID of the newly spawned agent, if it was created."), + ] = None + prompt: Annotated[ + str, + Field( + description="Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning." + ), + ] + reasoning_effort: Annotated[ + ReasoningEffort, + Field(description="Reasoning effort requested for the spawned agent."), + ] + sender_thread_id: Annotated[ThreadId, Field(description="Thread ID of the sender.")] + status: Annotated[ + AgentStatus, + Field( + description="Last known status of the new agent reported to the sender agent." + ), + ] + type: Annotated[ + Literal["collab_agent_spawn_end"], + Field(title="CollabAgentSpawnEndEventMsgType"), + ] + + +class CollabAgentInteractionBeginEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[str, Field(description="Identifier for the collab tool call.")] + prompt: Annotated[ + str, + Field( + description="Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning." + ), + ] + receiver_thread_id: Annotated[ + ThreadId, Field(description="Thread ID of the receiver.") + ] + sender_thread_id: Annotated[ThreadId, Field(description="Thread ID of the sender.")] + type: Annotated[ + Literal["collab_agent_interaction_begin"], + Field(title="CollabAgentInteractionBeginEventMsgType"), + ] + + +class CollabAgentInteractionEndEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[str, Field(description="Identifier for the collab tool call.")] + prompt: Annotated[ + str, + Field( + description="Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning." + ), + ] + receiver_agent_nickname: Annotated[ + str | None, + Field(description="Optional nickname assigned to the receiver agent."), + ] = None + receiver_agent_role: Annotated[ + str | None, Field(description="Optional role assigned to the receiver agent.") + ] = None + receiver_thread_id: Annotated[ + ThreadId, Field(description="Thread ID of the receiver.") + ] + sender_thread_id: Annotated[ThreadId, Field(description="Thread ID of the sender.")] + status: Annotated[ + AgentStatus, + Field( + description="Last known status of the receiver agent reported to the sender agent." + ), + ] + type: Annotated[ + Literal["collab_agent_interaction_end"], + Field(title="CollabAgentInteractionEndEventMsgType"), + ] + + +class CollabWaitingBeginEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[str, Field(description="ID of the waiting call.")] + receiver_agents: Annotated[ + list[CollabAgentRef] | None, + Field(description="Optional nicknames/roles for receivers."), + ] = None + receiver_thread_ids: Annotated[ + list[ThreadId], Field(description="Thread ID of the receivers.") + ] + sender_thread_id: Annotated[ThreadId, Field(description="Thread ID of the sender.")] + type: Annotated[ + Literal["collab_waiting_begin"], Field(title="CollabWaitingBeginEventMsgType") + ] + + +class CollabWaitingEndEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + agent_statuses: Annotated[ + list[CollabAgentStatusEntry] | None, + Field(description="Optional receiver metadata paired with final statuses."), + ] = None + call_id: Annotated[str, Field(description="ID of the waiting call.")] + sender_thread_id: Annotated[ThreadId, Field(description="Thread ID of the sender.")] + statuses: Annotated[ + dict[str, AgentStatus], + Field( + description="Last known status of the receiver agents reported to the sender agent." + ), + ] + type: Annotated[ + Literal["collab_waiting_end"], Field(title="CollabWaitingEndEventMsgType") + ] + + +class CollabCloseBeginEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[str, Field(description="Identifier for the collab tool call.")] + receiver_thread_id: Annotated[ + ThreadId, Field(description="Thread ID of the receiver.") + ] + sender_thread_id: Annotated[ThreadId, Field(description="Thread ID of the sender.")] + type: Annotated[ + Literal["collab_close_begin"], Field(title="CollabCloseBeginEventMsgType") + ] + + +class CollabCloseEndEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[str, Field(description="Identifier for the collab tool call.")] + receiver_agent_nickname: Annotated[ + str | None, + Field(description="Optional nickname assigned to the receiver agent."), + ] = None + receiver_agent_role: Annotated[ + str | None, Field(description="Optional role assigned to the receiver agent.") + ] = None + receiver_thread_id: Annotated[ + ThreadId, Field(description="Thread ID of the receiver.") + ] + sender_thread_id: Annotated[ThreadId, Field(description="Thread ID of the sender.")] + status: Annotated[ + AgentStatus, + Field( + description="Last known status of the receiver agent reported to the sender agent before the close." + ), + ] + type: Annotated[ + Literal["collab_close_end"], Field(title="CollabCloseEndEventMsgType") + ] + + +class CollabResumeBeginEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[str, Field(description="Identifier for the collab tool call.")] + receiver_agent_nickname: Annotated[ + str | None, + Field(description="Optional nickname assigned to the receiver agent."), + ] = None + receiver_agent_role: Annotated[ + str | None, Field(description="Optional role assigned to the receiver agent.") + ] = None + receiver_thread_id: Annotated[ + ThreadId, Field(description="Thread ID of the receiver.") + ] + sender_thread_id: Annotated[ThreadId, Field(description="Thread ID of the sender.")] + type: Annotated[ + Literal["collab_resume_begin"], Field(title="CollabResumeBeginEventMsgType") + ] + + +class CollabResumeEndEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[str, Field(description="Identifier for the collab tool call.")] + receiver_agent_nickname: Annotated[ + str | None, + Field(description="Optional nickname assigned to the receiver agent."), + ] = None + receiver_agent_role: Annotated[ + str | None, Field(description="Optional role assigned to the receiver agent.") + ] = None + receiver_thread_id: Annotated[ + ThreadId, Field(description="Thread ID of the receiver.") + ] + sender_thread_id: Annotated[ThreadId, Field(description="Thread ID of the sender.")] + status: Annotated[ + AgentStatus, + Field( + description="Last known status of the receiver agent reported to the sender agent after resume." + ), + ] + type: Annotated[ + Literal["collab_resume_end"], Field(title="CollabResumeEndEventMsgType") + ] + + +class ExperimentalFeature(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + announcement: Annotated[ + str | None, + Field( + description="Announcement copy shown to users when the feature is introduced. Null when this feature is not in beta." + ), + ] = None + default_enabled: Annotated[ + bool, + Field( + alias="defaultEnabled", + description="Whether this feature is enabled by default.", + ), + ] + description: Annotated[ + str | None, + Field( + description="Short summary describing what the feature does. Null when this feature is not in beta." + ), + ] = None + display_name: Annotated[ + str | None, + Field( + alias="displayName", + description="User-facing display name shown in the experimental features UI. Null when this feature is not in beta.", + ), + ] = None + enabled: Annotated[ + bool, + Field( + description="Whether this feature is currently enabled in the loaded config." + ), + ] + name: Annotated[ + str, Field(description="Stable key used in config.toml and CLI flag toggles.") + ] + stage: Annotated[ + ExperimentalFeatureStage, + Field(description="Lifecycle stage of this feature flag."), + ] + + +class ExperimentalFeatureListResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[ExperimentalFeature] + next_cursor: Annotated[ + str | None, + Field( + alias="nextCursor", + description="Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + ), + ] = None + + +class ExternalAgentConfigMigrationItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cwd: Annotated[ + str | None, + Field( + description="Null or empty means home-scoped migration; non-empty means repo-scoped migration." + ), + ] = None + description: str + item_type: Annotated[ExternalAgentConfigMigrationItemType, Field(alias="itemType")] + + +class FileUpdateChange(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + diff: str + kind: PatchChangeKind + path: str + + +class InputImageFunctionCallOutputContentItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + detail: ImageDetail | None = None + image_url: str + type: Annotated[ + Literal["input_image"], + Field(title="InputImageFunctionCallOutputContentItemType"), + ] + + +class FunctionCallOutputContentItem( + RootModel[ + InputTextFunctionCallOutputContentItem | InputImageFunctionCallOutputContentItem + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + InputTextFunctionCallOutputContentItem + | InputImageFunctionCallOutputContentItem, + Field( + description="Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs." + ), + ] + + +class GetAccountResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + account: Account | None = None + requires_openai_auth: Annotated[bool, Field(alias="requiresOpenaiAuth")] + + +class HookOutputEntry(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + kind: HookOutputEntryKind + text: str + + +class HookRunSummary(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + completed_at: Annotated[int | None, Field(alias="completedAt")] = None + display_order: Annotated[int, Field(alias="displayOrder")] + duration_ms: Annotated[int | None, Field(alias="durationMs")] = None + entries: list[HookOutputEntry] + event_name: Annotated[HookEventName, Field(alias="eventName")] + execution_mode: Annotated[HookExecutionMode, Field(alias="executionMode")] + handler_type: Annotated[HookHandlerType, Field(alias="handlerType")] + id: str + scope: HookScope + source_path: Annotated[str, Field(alias="sourcePath")] + started_at: Annotated[int, Field(alias="startedAt")] + status: HookRunStatus + status_message: Annotated[str | None, Field(alias="statusMessage")] = None + + +class HookStartedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + run: HookRunSummary + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str | None, Field(alias="turnId")] = None + + +class McpServerStatus(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + auth_status: Annotated[McpAuthStatus, Field(alias="authStatus")] + name: str + resource_templates: Annotated[ + list[ResourceTemplate], Field(alias="resourceTemplates") + ] + resources: list[Resource] + tools: dict[str, Tool] + + +class Model(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + availability_nux: Annotated[ + ModelAvailabilityNux | None, Field(alias="availabilityNux") + ] = None + default_reasoning_effort: Annotated[ + ReasoningEffort, Field(alias="defaultReasoningEffort") + ] + description: str + display_name: Annotated[str, Field(alias="displayName")] + hidden: bool + id: str + input_modalities: Annotated[ + list[InputModality] | None, Field(alias="inputModalities") + ] = ["text", "image"] + is_default: Annotated[bool, Field(alias="isDefault")] + model: str + supported_reasoning_efforts: Annotated[ + list[ReasoningEffortOption], Field(alias="supportedReasoningEfforts") + ] + supports_personality: Annotated[bool | None, Field(alias="supportsPersonality")] = ( + False + ) + upgrade: str | None = None + upgrade_info: Annotated[ModelUpgradeInfo | None, Field(alias="upgradeInfo")] = None + + +class ModelListResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[Model] + next_cursor: Annotated[ + str | None, + Field( + alias="nextCursor", + description="Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + ), + ] = None + + +class NetworkApprovalContext(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + host: str + protocol: NetworkApprovalProtocol + + +class NetworkPolicyAmendment(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + action: NetworkPolicyRuleAction + host: str + + +class OverriddenMetadata(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + effective_value: Annotated[Any, Field(alias="effectiveValue")] + message: str + overriding_layer: Annotated[ConfigLayerMetadata, Field(alias="overridingLayer")] + + +class PlanItemArg(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + status: StepStatus + step: str + + +class PluginMarketplaceEntry(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + name: str + path: AbsolutePathBuf + plugins: list[PluginSummary] + + +class RateLimitSnapshot(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + credits: CreditsSnapshot | None = None + limit_id: Annotated[str | None, Field(alias="limitId")] = None + limit_name: Annotated[str | None, Field(alias="limitName")] = None + plan_type: Annotated[PlanType | None, Field(alias="planType")] = None + primary: RateLimitWindow | None = None + secondary: RateLimitWindow | None = None + + +class InputTranscriptDeltaRealtimeEvent(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + input_transcript_delta: Annotated[ + RealtimeTranscriptDelta, Field(alias="InputTranscriptDelta") + ] + + +class OutputTranscriptDeltaRealtimeEvent(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + output_transcript_delta: Annotated[ + RealtimeTranscriptDelta, Field(alias="OutputTranscriptDelta") + ] + + +class RealtimeHandoffRequested(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + active_transcript: list[RealtimeTranscriptEntry] + handoff_id: str + input_transcript: str + item_id: str + + +class RequestUserInputQuestion(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + header: str + id: str + is_other: Annotated[bool | None, Field(alias="isOther")] = False + is_secret: Annotated[bool | None, Field(alias="isSecret")] = False + options: list[RequestUserInputQuestionOption] | None = None + question: str + + +class WebSearchCallResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + action: ResponsesApiWebSearchAction | None = None + id: str | None = None + status: str | None = None + type: Annotated[ + Literal["web_search_call"], Field(title="WebSearchCallResponseItemType") + ] + + +class ReviewCodeLocation(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + absolute_file_path: str + line_range: ReviewLineRange + + +class NetworkPolicyAmendment1(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + network_policy_amendment: NetworkPolicyAmendment + + +class NetworkPolicyAmendmentReviewDecision(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + network_policy_amendment: NetworkPolicyAmendment1 + + +class ReviewDecision( + RootModel[ + Literal["approved"] + | ApprovedExecpolicyAmendmentReviewDecision + | Literal["approved_for_session"] + | NetworkPolicyAmendmentReviewDecision + | Literal["denied"] + | Literal["abort"] + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + Literal["approved"] + | ApprovedExecpolicyAmendmentReviewDecision + | Literal["approved_for_session"] + | NetworkPolicyAmendmentReviewDecision + | Literal["denied"] + | Literal["abort"], + Field(description="User's decision in response to an ExecApprovalRequest."), + ] + + +class ReviewFinding(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + body: str + code_location: ReviewCodeLocation + confidence_score: float + priority: int + title: str + + +class ReviewOutputEvent(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + findings: list[ReviewFinding] + overall_confidence_score: float + overall_correctness: str + overall_explanation: str + + +class ReviewStartParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + delivery: Annotated[ + ReviewDelivery | None, + Field( + description="Where to run the review: inline (default) on the current thread or detached on a new thread (returned in `reviewThreadId`)." + ), + ] = None + target: ReviewTarget + thread_id: Annotated[str, Field(alias="threadId")] + + +class ErrorServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[Literal["error"], Field(title="ErrorNotificationMethod")] + params: ErrorNotification + + +class ThreadStatusChangedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/status/changed"], + Field(title="Thread/status/changedNotificationMethod"), + ] + params: ThreadStatusChangedNotification + + +class ThreadArchivedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/archived"], Field(title="Thread/archivedNotificationMethod") + ] + params: ThreadArchivedNotification + + +class ThreadUnarchivedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/unarchived"], Field(title="Thread/unarchivedNotificationMethod") + ] + params: ThreadUnarchivedNotification + + +class ThreadClosedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/closed"], Field(title="Thread/closedNotificationMethod") + ] + params: ThreadClosedNotification + + +class SkillsChangedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["skills/changed"], Field(title="Skills/changedNotificationMethod") + ] + params: SkillsChangedNotification + + +class ThreadNameUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/name/updated"], + Field(title="Thread/name/updatedNotificationMethod"), + ] + params: ThreadNameUpdatedNotification + + +class HookStartedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["hook/started"], Field(title="Hook/startedNotificationMethod") + ] + params: HookStartedNotification + + +class TurnDiffUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["turn/diff/updated"], Field(title="Turn/diff/updatedNotificationMethod") + ] + params: TurnDiffUpdatedNotification + + +class CommandExecOutputDeltaServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["command/exec/outputDelta"], + Field(title="Command/exec/outputDeltaNotificationMethod"), + ] + params: CommandExecOutputDeltaNotification + + +class ItemCommandExecutionTerminalInteractionServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["item/commandExecution/terminalInteraction"], + Field(title="Item/commandExecution/terminalInteractionNotificationMethod"), + ] + params: TerminalInteractionNotification + + +class ServerRequestResolvedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["serverRequest/resolved"], + Field(title="ServerRequest/resolvedNotificationMethod"), + ] + params: ServerRequestResolvedNotification + + +class AccountUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["account/updated"], Field(title="Account/updatedNotificationMethod") + ] + params: AccountUpdatedNotification + + +class ConfigWarningServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["configWarning"], Field(title="ConfigWarningNotificationMethod") + ] + params: ConfigWarningNotification + + +class ThreadRealtimeStartedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/realtime/started"], + Field(title="Thread/realtime/startedNotificationMethod"), + ] + params: ThreadRealtimeStartedNotification + + +class ThreadRealtimeItemAddedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/realtime/itemAdded"], + Field(title="Thread/realtime/itemAddedNotificationMethod"), + ] + params: ThreadRealtimeItemAddedNotification + + +class ThreadRealtimeOutputAudioDeltaServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/realtime/outputAudio/delta"], + Field(title="Thread/realtime/outputAudio/deltaNotificationMethod"), + ] + params: ThreadRealtimeOutputAudioDeltaNotification + + +class ThreadRealtimeErrorServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/realtime/error"], + Field(title="Thread/realtime/errorNotificationMethod"), + ] + params: ThreadRealtimeErrorNotification + + +class ThreadRealtimeClosedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/realtime/closed"], + Field(title="Thread/realtime/closedNotificationMethod"), + ] + params: ThreadRealtimeClosedNotification + + +class WindowsWorldWritableWarningServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["windows/worldWritableWarning"], + Field(title="Windows/worldWritableWarningNotificationMethod"), + ] + params: WindowsWorldWritableWarningNotification + + +class SkillDependencies(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + tools: list[SkillToolDependency] + + +class SkillMetadata(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + dependencies: SkillDependencies | None = None + description: str + enabled: bool + interface: SkillInterface | None = None + name: str + path: str + scope: SkillScope + short_description: Annotated[ + str | None, + Field( + alias="shortDescription", + description="Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", + ), + ] = None + + +class SkillsListEntry(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cwd: str + errors: list[SkillErrorInfo] + skills: list[SkillMetadata] + + +class SkillsListResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[SkillsListEntry] + + +class ThreadSpawn(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + agent_nickname: str | None = None + agent_role: str | None = None + depth: int + parent_thread_id: ThreadId + + +class ThreadSpawnSubAgentSource(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + thread_spawn: ThreadSpawn + + +class SubAgentSource( + RootModel[SubAgentSourceValue | ThreadSpawnSubAgentSource | OtherSubAgentSource] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: SubAgentSourceValue | ThreadSpawnSubAgentSource | OtherSubAgentSource + + +class UserMessageThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + content: list[UserInput] + id: str + type: Annotated[Literal["userMessage"], Field(title="UserMessageThreadItemType")] + + +class FileChangeThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + changes: list[FileUpdateChange] + id: str + status: PatchApplyStatus + type: Annotated[Literal["fileChange"], Field(title="FileChangeThreadItemType")] + + +class CollabAgentToolCallThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + agents_states: Annotated[ + dict[str, CollabAgentState], + Field( + alias="agentsStates", + description="Last known status of the target agents, when available.", + ), + ] + id: Annotated[ + str, Field(description="Unique identifier for this collab tool call.") + ] + model: Annotated[ + str | None, + Field(description="Model requested for the spawned agent, when applicable."), + ] = None + prompt: Annotated[ + str | None, + Field( + description="Prompt text sent as part of the collab tool call, when available." + ), + ] = None + reasoning_effort: Annotated[ + ReasoningEffort | None, + Field( + alias="reasoningEffort", + description="Reasoning effort requested for the spawned agent, when applicable.", + ), + ] = None + receiver_thread_ids: Annotated[ + list[str], + Field( + alias="receiverThreadIds", + description="Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + ), + ] + sender_thread_id: Annotated[ + str, + Field( + alias="senderThreadId", + description="Thread ID of the agent issuing the collab request.", + ), + ] + status: Annotated[ + CollabAgentToolCallStatus, + Field(description="Current status of the collab tool call."), + ] + tool: Annotated[ + CollabAgentTool, Field(description="Name of the collab tool that was invoked.") + ] + type: Annotated[ + Literal["collabAgentToolCall"], Field(title="CollabAgentToolCallThreadItemType") + ] + + +class WebSearchThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + action: WebSearchAction | None = None + id: str + query: str + type: Annotated[Literal["webSearch"], Field(title="WebSearchThreadItemType")] + + +class ThreadItem( + RootModel[ + UserMessageThreadItem + | AgentMessageThreadItem + | PlanThreadItem + | ReasoningThreadItem + | CommandExecutionThreadItem + | FileChangeThreadItem + | McpToolCallThreadItem + | DynamicToolCallThreadItem + | CollabAgentToolCallThreadItem + | WebSearchThreadItem + | ImageViewThreadItem + | ImageGenerationThreadItem + | EnteredReviewModeThreadItem + | ExitedReviewModeThreadItem + | ContextCompactionThreadItem + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + UserMessageThreadItem + | AgentMessageThreadItem + | PlanThreadItem + | ReasoningThreadItem + | CommandExecutionThreadItem + | FileChangeThreadItem + | McpToolCallThreadItem + | DynamicToolCallThreadItem + | CollabAgentToolCallThreadItem + | WebSearchThreadItem + | ImageViewThreadItem + | ImageGenerationThreadItem + | EnteredReviewModeThreadItem + | ExitedReviewModeThreadItem + | ContextCompactionThreadItem + ) + + +class ThreadListParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + archived: Annotated[ + bool | None, + Field( + description="Optional archived filter; when set to true, only archived threads are returned. If false or null, only non-archived threads are returned." + ), + ] = None + cursor: Annotated[ + str | None, + Field(description="Opaque pagination cursor returned by a previous call."), + ] = None + cwd: Annotated[ + str | None, + Field( + description="Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned." + ), + ] = None + limit: Annotated[ + int | None, + Field( + description="Optional page size; defaults to a reasonable server-side value.", + ge=0, + ), + ] = None + model_providers: Annotated[ + list[str] | None, + Field( + alias="modelProviders", + description="Optional provider filter; when set, only sessions recorded under these providers are returned. When present but empty, includes all providers.", + ), + ] = None + search_term: Annotated[ + str | None, + Field( + alias="searchTerm", + description="Optional substring filter for the extracted thread title.", + ), + ] = None + sort_key: Annotated[ + ThreadSortKey | None, + Field( + alias="sortKey", description="Optional sort key; defaults to created_at." + ), + ] = None + source_kinds: Annotated[ + list[ThreadSourceKind] | None, + Field( + alias="sourceKinds", + description="Optional source filter; when set, only sessions from these source kinds are returned. When omitted or empty, defaults to interactive sources.", + ), + ] = None + + +class ThreadTokenUsage(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + last: TokenUsageBreakdown + model_context_window: Annotated[int | None, Field(alias="modelContextWindow")] = ( + None + ) + total: TokenUsageBreakdown + + +class ThreadTokenUsageUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + token_usage: Annotated[ThreadTokenUsage, Field(alias="tokenUsage")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class ThreadUnsubscribeResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + status: ThreadUnsubscribeStatus + + +class ToolsV2(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + view_image: bool | None = None + web_search: WebSearchToolConfig | None = None + + +class Turn(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + error: Annotated[ + TurnError | None, + Field(description="Only populated when the Turn's status is failed."), + ] = None + id: str + items: Annotated[ + list[ThreadItem], + Field( + description="Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list." + ), + ] + status: TurnStatus + + +class TurnCompletedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + turn: Turn + + +class UserMessageTurnItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + content: list[UserInput] + id: str + type: Annotated[Literal["UserMessage"], Field(title="UserMessageTurnItemType")] + + +class TurnItem( + RootModel[ + UserMessageTurnItem + | AgentMessageTurnItem + | PlanTurnItem + | ReasoningTurnItem + | WebSearchTurnItem + | ImageGenerationTurnItem + | ContextCompactionTurnItem + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + UserMessageTurnItem + | AgentMessageTurnItem + | PlanTurnItem + | ReasoningTurnItem + | WebSearchTurnItem + | ImageGenerationTurnItem + | ContextCompactionTurnItem + ) + + +class TurnPlanStep(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + status: TurnPlanStepStatus + step: str + + +class TurnPlanUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + explanation: str | None = None + plan: list[TurnPlanStep] + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class TurnStartParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + approval_policy: Annotated[ + AskForApproval | None, + Field( + alias="approvalPolicy", + description="Override the approval policy for this turn and subsequent turns.", + ), + ] = None + cwd: Annotated[ + str | None, + Field( + description="Override the working directory for this turn and subsequent turns." + ), + ] = None + effort: Annotated[ + ReasoningEffort | None, + Field( + description="Override the reasoning effort for this turn and subsequent turns." + ), + ] = None + input: list[UserInput] + model: Annotated[ + str | None, + Field(description="Override the model for this turn and subsequent turns."), + ] = None + output_schema: Annotated[ + Any | None, + Field( + alias="outputSchema", + description="Optional JSON Schema used to constrain the final assistant message for this turn.", + ), + ] = None + personality: Annotated[ + Personality | None, + Field( + description="Override the personality for this turn and subsequent turns." + ), + ] = None + sandbox_policy: Annotated[ + SandboxPolicy | None, + Field( + alias="sandboxPolicy", + description="Override the sandbox policy for this turn and subsequent turns.", + ), + ] = None + service_tier: Annotated[ + ServiceTier | None, + Field( + alias="serviceTier", + description="Override the service tier for this turn and subsequent turns.", + ), + ] = None + summary: Annotated[ + ReasoningSummary | None, + Field( + description="Override the reasoning summary for this turn and subsequent turns." + ), + ] = None + thread_id: Annotated[str, Field(alias="threadId")] + + +class TurnStartResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + turn: Turn + + +class TurnStartedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + turn: Turn + + +class TurnSteerParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + expected_turn_id: Annotated[ + str, + Field( + alias="expectedTurnId", + description="Required active turn id precondition. The request fails when it does not match the currently active turn.", + ), + ] + input: list[UserInput] + thread_id: Annotated[str, Field(alias="threadId")] + + +class WindowsSandboxSetupCompletedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + error: str | None = None + mode: WindowsSandboxSetupMode + success: bool + + +class AccountRateLimitsUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + rate_limits: Annotated[RateLimitSnapshot, Field(alias="rateLimits")] + + +class AppInfo(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + app_metadata: Annotated[AppMetadata | None, Field(alias="appMetadata")] = None + branding: AppBranding | None = None + description: str | None = None + distribution_channel: Annotated[str | None, Field(alias="distributionChannel")] = ( + None + ) + id: str + install_url: Annotated[str | None, Field(alias="installUrl")] = None + is_accessible: Annotated[bool | None, Field(alias="isAccessible")] = False + is_enabled: Annotated[ + bool | None, + Field( + alias="isEnabled", + description="Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", + ), + ] = True + labels: dict[str, Any] | None = None + logo_url: Annotated[str | None, Field(alias="logoUrl")] = None + logo_url_dark: Annotated[str | None, Field(alias="logoUrlDark")] = None + name: str + plugin_display_names: Annotated[ + list[str] | None, Field(alias="pluginDisplayNames") + ] = [] + + +class AppListUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[AppInfo] + + +class AppsListResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[AppInfo] + next_cursor: Annotated[ + str | None, + Field( + alias="nextCursor", + description="Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + ), + ] = None + + +class ThreadListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/list"], Field(title="Thread/listRequestMethod")] + params: ThreadListParams + + +class TurnStartRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["turn/start"], Field(title="Turn/startRequestMethod")] + params: TurnStartParams + + +class TurnSteerRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["turn/steer"], Field(title="Turn/steerRequestMethod")] + params: TurnSteerParams + + +class ReviewStartRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["review/start"], Field(title="Review/startRequestMethod")] + params: ReviewStartParams + + +class CommandExecRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["command/exec"], Field(title="Command/execRequestMethod")] + params: CommandExecParams + + +class CommandExecResizeRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["command/exec/resize"], Field(title="Command/exec/resizeRequestMethod") + ] + params: CommandExecResizeParams + + +class ConfigValueWriteRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["config/value/write"], Field(title="Config/value/writeRequestMethod") + ] + params: ConfigValueWriteParams + + +class ConfigBatchWriteParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + edits: list[ConfigEdit] + expected_version: Annotated[str | None, Field(alias="expectedVersion")] = None + file_path: Annotated[ + str | None, + Field( + alias="filePath", + description="Path to the config file to write; defaults to the user's `config.toml` when omitted.", + ), + ] = None + reload_user_config: Annotated[ + bool | None, + Field( + alias="reloadUserConfig", + description="When true, hot-reload the updated user config into all loaded threads after writing.", + ), + ] = None + + +class ConfigWriteResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + file_path: Annotated[ + AbsolutePathBuf, + Field( + alias="filePath", + description="Canonical path to the config file that was written.", + ), + ] + overridden_metadata: Annotated[ + OverriddenMetadata | None, Field(alias="overriddenMetadata") + ] = None + status: WriteStatus + version: str + + +class TokenCountEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + info: TokenUsageInfo | None = None + rate_limits: RateLimitSnapshot | None = None + type: Annotated[Literal["token_count"], Field(title="TokenCountEventMsgType")] + + +class ExecApprovalRequestEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + additional_permissions: Annotated[ + PermissionProfile | None, + Field( + description="Optional additional filesystem permissions requested for this command." + ), + ] = None + approval_id: Annotated[ + str | None, + Field( + description="Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept)." + ), + ] = None + available_decisions: Annotated[ + list[ReviewDecision] | None, + Field( + description="Ordered list of decisions the client may present for this prompt.\n\nWhen absent, clients should derive the legacy default set from the other fields on this request." + ), + ] = None + call_id: Annotated[ + str, Field(description="Identifier for the associated command execution item.") + ] + command: Annotated[list[str], Field(description="The command to be executed.")] + cwd: Annotated[str, Field(description="The command's working directory.")] + network_approval_context: Annotated[ + NetworkApprovalContext | None, + Field( + description="Optional network context for a blocked request that can be approved." + ), + ] = None + parsed_cmd: list[ParsedCommand] + proposed_execpolicy_amendment: Annotated[ + list[str] | None, + Field( + description="Proposed execpolicy amendment that can be applied to allow future runs." + ), + ] = None + proposed_network_policy_amendments: Annotated[ + list[NetworkPolicyAmendment] | None, + Field( + description="Proposed network policy amendments (for example allow/deny this host in future)." + ), + ] = None + reason: Annotated[ + str | None, + Field( + description="Optional human-readable reason for the approval (e.g. retry without sandbox)." + ), + ] = None + skill_metadata: Annotated[ + ExecApprovalRequestSkillMetadata | None, + Field( + description="Optional skill metadata when the approval was triggered by a skill script." + ), + ] = None + turn_id: Annotated[ + str | None, + Field( + description="Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility." + ), + ] = "" + type: Annotated[ + Literal["exec_approval_request"], Field(title="ExecApprovalRequestEventMsgType") + ] + + +class RequestUserInputEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: Annotated[ + str, + Field( + description="Responses API call id for the associated tool call, if available." + ), + ] + questions: list[RequestUserInputQuestion] + turn_id: Annotated[ + str | None, + Field( + description="Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility." + ), + ] = "" + type: Annotated[ + Literal["request_user_input"], Field(title="RequestUserInputEventMsgType") + ] + + +class ListSkillsResponseEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + skills: list[SkillsListEntry] + type: Annotated[ + Literal["list_skills_response"], Field(title="ListSkillsResponseEventMsgType") + ] + + +class PlanUpdateEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + explanation: Annotated[ + str | None, + Field( + description="Arguments for the `update_plan` todo/checklist tool (not plan mode)." + ), + ] = None + plan: list[PlanItemArg] + type: Annotated[Literal["plan_update"], Field(title="PlanUpdateEventMsgType")] + + +class ExitedReviewModeEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + review_output: ReviewOutputEvent | None = None + type: Annotated[ + Literal["exited_review_mode"], Field(title="ExitedReviewModeEventMsgType") + ] + + +class ItemStartedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item: TurnItem + thread_id: ThreadId + turn_id: str + type: Annotated[Literal["item_started"], Field(title="ItemStartedEventMsgType")] + + +class ItemCompletedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item: TurnItem + thread_id: ThreadId + turn_id: str + type: Annotated[Literal["item_completed"], Field(title="ItemCompletedEventMsgType")] + + +class HookStartedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + run: HookRunSummary + turn_id: str | None = None + type: Annotated[Literal["hook_started"], Field(title="HookStartedEventMsgType")] + + +class HookCompletedEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + run: HookRunSummary + turn_id: str | None = None + type: Annotated[Literal["hook_completed"], Field(title="HookCompletedEventMsgType")] + + +class ExternalAgentConfigDetectResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + items: list[ExternalAgentConfigMigrationItem] + + +class ExternalAgentConfigImportParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + migration_items: Annotated[ + list[ExternalAgentConfigMigrationItem], Field(alias="migrationItems") + ] + + +class FunctionCallOutputBody(RootModel[str | list[FunctionCallOutputContentItem]]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: str | list[FunctionCallOutputContentItem] + + +class FunctionCallOutputPayload(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + body: FunctionCallOutputBody + success: bool | None = None + + +class GetAccountRateLimitsResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + rate_limits: Annotated[ + RateLimitSnapshot, + Field( + alias="rateLimits", + description="Backward-compatible single-bucket view; mirrors the historical payload.", + ), + ] + rate_limits_by_limit_id: Annotated[ + dict[str, Any] | None, + Field( + alias="rateLimitsByLimitId", + description="Multi-bucket view keyed by metered `limit_id` (for example, `codex`).", + ), + ] = None + + +class HookCompletedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + run: HookRunSummary + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str | None, Field(alias="turnId")] = None + + +class ItemCompletedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item: ThreadItem + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class ItemStartedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item: ThreadItem + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class ListMcpServerStatusResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[McpServerStatus] + next_cursor: Annotated[ + str | None, + Field( + alias="nextCursor", + description="Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + ), + ] = None + + +class PluginListResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + marketplaces: list[PluginMarketplaceEntry] + remote_sync_error: Annotated[str | None, Field(alias="remoteSyncError")] = None + + +class ProfileV2(BaseModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + approval_policy: AskForApproval | None = None + chatgpt_base_url: str | None = None + model: str | None = None + model_provider: str | None = None + model_reasoning_effort: ReasoningEffort | None = None + model_reasoning_summary: ReasoningSummary | None = None + model_verbosity: Verbosity | None = None + service_tier: ServiceTier | None = None + tools: ToolsV2 | None = None + web_search: WebSearchMode | None = None + + +class HandoffRequestedRealtimeEvent(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + handoff_requested: Annotated[ + RealtimeHandoffRequested, Field(alias="HandoffRequested") + ] + + +class RealtimeEvent( + RootModel[ + SessionUpdatedRealtimeEvent + | InputTranscriptDeltaRealtimeEvent + | OutputTranscriptDeltaRealtimeEvent + | AudioOutRealtimeEvent + | ConversationItemAddedRealtimeEvent + | ConversationItemDoneRealtimeEvent + | HandoffRequestedRealtimeEvent + | ErrorRealtimeEvent + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + SessionUpdatedRealtimeEvent + | InputTranscriptDeltaRealtimeEvent + | OutputTranscriptDeltaRealtimeEvent + | AudioOutRealtimeEvent + | ConversationItemAddedRealtimeEvent + | ConversationItemDoneRealtimeEvent + | HandoffRequestedRealtimeEvent + | ErrorRealtimeEvent + ) + + +class FunctionCallOutputResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: str + output: FunctionCallOutputPayload + type: Annotated[ + Literal["function_call_output"], + Field(title="FunctionCallOutputResponseItemType"), + ] + + +class CustomToolCallOutputResponseItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + call_id: str + output: FunctionCallOutputPayload + type: Annotated[ + Literal["custom_tool_call_output"], + Field(title="CustomToolCallOutputResponseItemType"), + ] + + +class ResponseItem( + RootModel[ + MessageResponseItem + | ReasoningResponseItem + | LocalShellCallResponseItem + | FunctionCallResponseItem + | ToolSearchCallResponseItem + | FunctionCallOutputResponseItem + | CustomToolCallResponseItem + | CustomToolCallOutputResponseItem + | ToolSearchOutputResponseItem + | WebSearchCallResponseItem + | ImageGenerationCallResponseItem + | GhostSnapshotResponseItem + | CompactionResponseItem + | OtherResponseItem + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: ( + MessageResponseItem + | ReasoningResponseItem + | LocalShellCallResponseItem + | FunctionCallResponseItem + | ToolSearchCallResponseItem + | FunctionCallOutputResponseItem + | CustomToolCallResponseItem + | CustomToolCallOutputResponseItem + | ToolSearchOutputResponseItem + | WebSearchCallResponseItem + | ImageGenerationCallResponseItem + | GhostSnapshotResponseItem + | CompactionResponseItem + | OtherResponseItem + ) + + +class ReviewStartResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + review_thread_id: Annotated[ + str, + Field( + alias="reviewThreadId", + description="Identifies the thread where the review runs.\n\nFor inline reviews, this is the original thread id. For detached reviews, this is the id of the new review thread.", + ), + ] + turn: Turn + + +class ThreadTokenUsageUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/tokenUsage/updated"], + Field(title="Thread/tokenUsage/updatedNotificationMethod"), + ] + params: ThreadTokenUsageUpdatedNotification + + +class TurnStartedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["turn/started"], Field(title="Turn/startedNotificationMethod") + ] + params: TurnStartedNotification + + +class TurnCompletedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["turn/completed"], Field(title="Turn/completedNotificationMethod") + ] + params: TurnCompletedNotification + + +class HookCompletedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["hook/completed"], Field(title="Hook/completedNotificationMethod") + ] + params: HookCompletedNotification + + +class TurnPlanUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["turn/plan/updated"], Field(title="Turn/plan/updatedNotificationMethod") + ] + params: TurnPlanUpdatedNotification + + +class ItemStartedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["item/started"], Field(title="Item/startedNotificationMethod") + ] + params: ItemStartedNotification + + +class ItemCompletedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["item/completed"], Field(title="Item/completedNotificationMethod") + ] + params: ItemCompletedNotification + + +class AccountRateLimitsUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["account/rateLimits/updated"], + Field(title="Account/rateLimits/updatedNotificationMethod"), + ] + params: AccountRateLimitsUpdatedNotification + + +class AppListUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["app/list/updated"], Field(title="App/list/updatedNotificationMethod") + ] + params: AppListUpdatedNotification + + +class WindowsSandboxSetupCompletedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["windowsSandbox/setupCompleted"], + Field(title="WindowsSandbox/setupCompletedNotificationMethod"), + ] + params: WindowsSandboxSetupCompletedNotification + + +class SubAgentSessionSource(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + sub_agent: Annotated[SubAgentSource, Field(alias="subAgent")] + + +class SessionSource(RootModel[SessionSourceValue | SubAgentSessionSource]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: SessionSourceValue | SubAgentSessionSource + + +class Thread(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + agent_nickname: Annotated[ + str | None, + Field( + alias="agentNickname", + description="Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + ), + ] = None + agent_role: Annotated[ + str | None, + Field( + alias="agentRole", + description="Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + ), + ] = None + cli_version: Annotated[ + str, + Field( + alias="cliVersion", + description="Version of the CLI that created the thread.", + ), + ] + created_at: Annotated[ + int, + Field( + alias="createdAt", + description="Unix timestamp (in seconds) when the thread was created.", + ), + ] + cwd: Annotated[str, Field(description="Working directory captured for the thread.")] + ephemeral: Annotated[ + bool, + Field( + description="Whether the thread is ephemeral and should not be materialized on disk." + ), + ] + git_info: Annotated[ + GitInfo | None, + Field( + alias="gitInfo", + description="Optional Git metadata captured when the thread was created.", + ), + ] = None + id: str + model_provider: Annotated[ + str, + Field( + alias="modelProvider", + description="Model provider used for this thread (for example, 'openai').", + ), + ] + name: Annotated[ + str | None, Field(description="Optional user-facing thread title.") + ] = None + path: Annotated[ + str | None, Field(description="[UNSTABLE] Path to the thread on disk.") + ] = None + preview: Annotated[ + str, + Field( + description="Usually the first user message in the thread, if available." + ), + ] + source: Annotated[ + SessionSource, + Field( + description="Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." + ), + ] + status: Annotated[ + ThreadStatus, Field(description="Current runtime status for the thread.") + ] + turns: Annotated[ + list[Turn], + Field( + description="Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list." + ), + ] + updated_at: Annotated[ + int, + Field( + alias="updatedAt", + description="Unix timestamp (in seconds) when the thread was last updated.", + ), + ] + + +class ThreadForkResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + approval_policy: Annotated[AskForApproval, Field(alias="approvalPolicy")] + cwd: str + model: str + model_provider: Annotated[str, Field(alias="modelProvider")] + reasoning_effort: Annotated[ + ReasoningEffort | None, Field(alias="reasoningEffort") + ] = None + sandbox: SandboxPolicy + service_tier: Annotated[ServiceTier | None, Field(alias="serviceTier")] = None + thread: Thread + + +class ThreadListResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[Thread] + next_cursor: Annotated[ + str | None, + Field( + alias="nextCursor", + description="Opaque cursor to pass to the next call to continue after the last item. if None, there are no more items to return.", + ), + ] = None + + +class ThreadMetadataUpdateResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread: Thread + + +class ThreadReadResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread: Thread + + +class ThreadResumeResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + approval_policy: Annotated[AskForApproval, Field(alias="approvalPolicy")] + cwd: str + model: str + model_provider: Annotated[str, Field(alias="modelProvider")] + reasoning_effort: Annotated[ + ReasoningEffort | None, Field(alias="reasoningEffort") + ] = None + sandbox: SandboxPolicy + service_tier: Annotated[ServiceTier | None, Field(alias="serviceTier")] = None + thread: Thread + + +class ThreadRollbackResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread: Annotated[ + Thread, + Field( + description="The updated thread after applying the rollback, with `turns` populated.\n\nThe ThreadItems stored in each Turn are lossy since we explicitly do not persist all agent interactions, such as command executions. This is the same behavior as `thread/resume`." + ), + ] + + +class ThreadStartResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + approval_policy: Annotated[AskForApproval, Field(alias="approvalPolicy")] + cwd: str + model: str + model_provider: Annotated[str, Field(alias="modelProvider")] + reasoning_effort: Annotated[ + ReasoningEffort | None, Field(alias="reasoningEffort") + ] = None + sandbox: SandboxPolicy + service_tier: Annotated[ServiceTier | None, Field(alias="serviceTier")] = None + thread: Thread + + +class ThreadStartedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread: Thread + + +class ThreadUnarchiveResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread: Thread + + +class ExternalAgentConfigImportRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["externalAgentConfig/import"], + Field(title="ExternalAgentConfig/importRequestMethod"), + ] + params: ExternalAgentConfigImportParams + + +class ConfigBatchWriteRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["config/batchWrite"], Field(title="Config/batchWriteRequestMethod") + ] + params: ConfigBatchWriteParams + + +class ClientRequest( + RootModel[ + InitializeRequest + | ThreadStartRequest + | ThreadResumeRequest + | ThreadForkRequest + | ThreadArchiveRequest + | ThreadUnsubscribeRequest + | ThreadNameSetRequest + | ThreadMetadataUpdateRequest + | ThreadUnarchiveRequest + | ThreadCompactStartRequest + | ThreadRollbackRequest + | ThreadListRequest + | ThreadLoadedListRequest + | ThreadReadRequest + | SkillsListRequest + | PluginListRequest + | SkillsRemoteListRequest + | SkillsRemoteExportRequest + | AppListRequest + | SkillsConfigWriteRequest + | PluginInstallRequest + | PluginUninstallRequest + | TurnStartRequest + | TurnSteerRequest + | TurnInterruptRequest + | ReviewStartRequest + | ModelListRequest + | ExperimentalFeatureListRequest + | McpServerOauthLoginRequest + | ConfigMcpServerReloadRequest + | McpServerStatusListRequest + | WindowsSandboxSetupStartRequest + | AccountLoginStartRequest + | AccountLoginCancelRequest + | AccountLogoutRequest + | AccountRateLimitsReadRequest + | FeedbackUploadRequest + | CommandExecRequest + | CommandExecWriteRequest + | CommandExecTerminateRequest + | CommandExecResizeRequest + | ConfigReadRequest + | ExternalAgentConfigDetectRequest + | ExternalAgentConfigImportRequest + | ConfigValueWriteRequest + | ConfigBatchWriteRequest + | ConfigRequirementsReadRequest + | AccountReadRequest + | FuzzyFileSearchRequest + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + InitializeRequest + | ThreadStartRequest + | ThreadResumeRequest + | ThreadForkRequest + | ThreadArchiveRequest + | ThreadUnsubscribeRequest + | ThreadNameSetRequest + | ThreadMetadataUpdateRequest + | ThreadUnarchiveRequest + | ThreadCompactStartRequest + | ThreadRollbackRequest + | ThreadListRequest + | ThreadLoadedListRequest + | ThreadReadRequest + | SkillsListRequest + | PluginListRequest + | SkillsRemoteListRequest + | SkillsRemoteExportRequest + | AppListRequest + | SkillsConfigWriteRequest + | PluginInstallRequest + | PluginUninstallRequest + | TurnStartRequest + | TurnSteerRequest + | TurnInterruptRequest + | ReviewStartRequest + | ModelListRequest + | ExperimentalFeatureListRequest + | McpServerOauthLoginRequest + | ConfigMcpServerReloadRequest + | McpServerStatusListRequest + | WindowsSandboxSetupStartRequest + | AccountLoginStartRequest + | AccountLoginCancelRequest + | AccountLogoutRequest + | AccountRateLimitsReadRequest + | FeedbackUploadRequest + | CommandExecRequest + | CommandExecWriteRequest + | CommandExecTerminateRequest + | CommandExecResizeRequest + | ConfigReadRequest + | ExternalAgentConfigDetectRequest + | ExternalAgentConfigImportRequest + | ConfigValueWriteRequest + | ConfigBatchWriteRequest + | ConfigRequirementsReadRequest + | AccountReadRequest + | FuzzyFileSearchRequest, + Field( + description="Request from the client to the server.", title="ClientRequest" + ), + ] + + +class Config(BaseModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + analytics: AnalyticsConfig | None = None + approval_policy: AskForApproval | None = None + compact_prompt: str | None = None + developer_instructions: str | None = None + forced_chatgpt_workspace_id: str | None = None + forced_login_method: ForcedLoginMethod | None = None + instructions: str | None = None + model: str | None = None + model_auto_compact_token_limit: int | None = None + model_context_window: int | None = None + model_provider: str | None = None + model_reasoning_effort: ReasoningEffort | None = None + model_reasoning_summary: ReasoningSummary | None = None + model_verbosity: Verbosity | None = None + profile: str | None = None + profiles: dict[str, ProfileV2] | None = {} + review_model: str | None = None + sandbox_mode: SandboxMode | None = None + sandbox_workspace_write: SandboxWorkspaceWrite | None = None + service_tier: ServiceTier | None = None + tools: ToolsV2 | None = None + web_search: WebSearchMode | None = None + + +class ConfigReadResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + config: Config + layers: list[ConfigLayer] | None = None + origins: dict[str, ConfigLayerMetadata] + + +class RealtimeConversationRealtimeEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + payload: RealtimeEvent + type: Annotated[ + Literal["realtime_conversation_realtime"], + Field(title="RealtimeConversationRealtimeEventMsgType"), + ] + + +class RawResponseItemEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item: ResponseItem + type: Annotated[ + Literal["raw_response_item"], Field(title="RawResponseItemEventMsgType") + ] + + +class RawResponseItemCompletedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item: ResponseItem + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class ThreadStartedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/started"], Field(title="Thread/startedNotificationMethod") + ] + params: ThreadStartedNotification + + +class ServerNotification( + RootModel[ + ErrorServerNotification + | ThreadStartedServerNotification + | ThreadStatusChangedServerNotification + | ThreadArchivedServerNotification + | ThreadUnarchivedServerNotification + | ThreadClosedServerNotification + | SkillsChangedServerNotification + | ThreadNameUpdatedServerNotification + | ThreadTokenUsageUpdatedServerNotification + | TurnStartedServerNotification + | HookStartedServerNotification + | TurnCompletedServerNotification + | HookCompletedServerNotification + | TurnDiffUpdatedServerNotification + | TurnPlanUpdatedServerNotification + | ItemStartedServerNotification + | ItemCompletedServerNotification + | ItemAgentMessageDeltaServerNotification + | ItemPlanDeltaServerNotification + | CommandExecOutputDeltaServerNotification + | ItemCommandExecutionOutputDeltaServerNotification + | ItemCommandExecutionTerminalInteractionServerNotification + | ItemFileChangeOutputDeltaServerNotification + | ServerRequestResolvedServerNotification + | ItemMcpToolCallProgressServerNotification + | McpServerOauthLoginCompletedServerNotification + | AccountUpdatedServerNotification + | AccountRateLimitsUpdatedServerNotification + | AppListUpdatedServerNotification + | ItemReasoningSummaryTextDeltaServerNotification + | ItemReasoningSummaryPartAddedServerNotification + | ItemReasoningTextDeltaServerNotification + | ThreadCompactedServerNotification + | ModelReroutedServerNotification + | DeprecationNoticeServerNotification + | ConfigWarningServerNotification + | FuzzyFileSearchSessionUpdatedServerNotification + | FuzzyFileSearchSessionCompletedServerNotification + | ThreadRealtimeStartedServerNotification + | ThreadRealtimeItemAddedServerNotification + | ThreadRealtimeOutputAudioDeltaServerNotification + | ThreadRealtimeErrorServerNotification + | ThreadRealtimeClosedServerNotification + | WindowsWorldWritableWarningServerNotification + | WindowsSandboxSetupCompletedServerNotification + | AccountLoginCompletedServerNotification + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + ErrorServerNotification + | ThreadStartedServerNotification + | ThreadStatusChangedServerNotification + | ThreadArchivedServerNotification + | ThreadUnarchivedServerNotification + | ThreadClosedServerNotification + | SkillsChangedServerNotification + | ThreadNameUpdatedServerNotification + | ThreadTokenUsageUpdatedServerNotification + | TurnStartedServerNotification + | HookStartedServerNotification + | TurnCompletedServerNotification + | HookCompletedServerNotification + | TurnDiffUpdatedServerNotification + | TurnPlanUpdatedServerNotification + | ItemStartedServerNotification + | ItemCompletedServerNotification + | ItemAgentMessageDeltaServerNotification + | ItemPlanDeltaServerNotification + | CommandExecOutputDeltaServerNotification + | ItemCommandExecutionOutputDeltaServerNotification + | ItemCommandExecutionTerminalInteractionServerNotification + | ItemFileChangeOutputDeltaServerNotification + | ServerRequestResolvedServerNotification + | ItemMcpToolCallProgressServerNotification + | McpServerOauthLoginCompletedServerNotification + | AccountUpdatedServerNotification + | AccountRateLimitsUpdatedServerNotification + | AppListUpdatedServerNotification + | ItemReasoningSummaryTextDeltaServerNotification + | ItemReasoningSummaryPartAddedServerNotification + | ItemReasoningTextDeltaServerNotification + | ThreadCompactedServerNotification + | ModelReroutedServerNotification + | DeprecationNoticeServerNotification + | ConfigWarningServerNotification + | FuzzyFileSearchSessionUpdatedServerNotification + | FuzzyFileSearchSessionCompletedServerNotification + | ThreadRealtimeStartedServerNotification + | ThreadRealtimeItemAddedServerNotification + | ThreadRealtimeOutputAudioDeltaServerNotification + | ThreadRealtimeErrorServerNotification + | ThreadRealtimeClosedServerNotification + | WindowsWorldWritableWarningServerNotification + | WindowsSandboxSetupCompletedServerNotification + | AccountLoginCompletedServerNotification, + Field( + description="Notification sent from the server to the client.", + title="ServerNotification", + ), + ] + + +class SessionConfiguredEventMsg(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + approval_policy: Annotated[ + AskForApproval, Field(description="When to escalate for approval for execution") + ] + cwd: Annotated[ + str, + Field( + description="Working directory that should be treated as the *root* of the session." + ), + ] + forked_from_id: ThreadId | None = None + history_entry_count: Annotated[ + int, Field(description="Current number of entries in the history log.", ge=0) + ] + history_log_id: Annotated[ + int, + Field( + description="Identifier of the history log file (inode on Unix, 0 otherwise).", + ge=0, + ), + ] + initial_messages: Annotated[ + list[EventMsg] | None, + Field( + description="Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history." + ), + ] = None + model: Annotated[ + str, Field(description="Tell the client what model is being queried.") + ] + model_provider_id: str + network_proxy: Annotated[ + SessionNetworkProxyRuntime | None, + Field( + description="Runtime proxy bind addresses, when the managed proxy was started for this session." + ), + ] = None + reasoning_effort: Annotated[ + ReasoningEffort | None, + Field( + description="The effort the model is putting into reasoning about the user's request." + ), + ] = None + rollout_path: Annotated[ + str | None, + Field( + description="Path in which the rollout is stored. Can be `None` for ephemeral threads" + ), + ] = None + sandbox_policy: Annotated[ + SandboxPolicy, + Field(description="How to sandbox commands executed in the system"), + ] + service_tier: ServiceTier | None = None + session_id: ThreadId + thread_name: Annotated[ + str | None, + Field(description="Optional user-facing thread name (may be unset)."), + ] = None + type: Annotated[ + Literal["session_configured"], Field(title="SessionConfiguredEventMsgType") + ] + + +class EventMsg( + RootModel[ + ErrorEventMsg + | WarningEventMsg + | RealtimeConversationStartedEventMsg + | RealtimeConversationRealtimeEventMsg + | RealtimeConversationClosedEventMsg + | ModelRerouteEventMsg + | ContextCompactedEventMsg + | ThreadRolledBackEventMsg + | TaskStartedEventMsg + | TaskCompleteEventMsg + | TokenCountEventMsg + | AgentMessageEventMsg + | UserMessageEventMsg + | AgentMessageDeltaEventMsg + | AgentReasoningEventMsg + | AgentReasoningDeltaEventMsg + | AgentReasoningRawContentEventMsg + | AgentReasoningRawContentDeltaEventMsg + | AgentReasoningSectionBreakEventMsg + | SessionConfiguredEventMsg + | ThreadNameUpdatedEventMsg + | McpStartupUpdateEventMsg + | McpStartupCompleteEventMsg + | McpToolCallBeginEventMsg + | McpToolCallEndEventMsg + | WebSearchBeginEventMsg + | WebSearchEndEventMsg + | ImageGenerationBeginEventMsg + | ImageGenerationEndEventMsg + | ExecCommandBeginEventMsg + | ExecCommandOutputDeltaEventMsg + | TerminalInteractionEventMsg + | ExecCommandEndEventMsg + | ViewImageToolCallEventMsg + | ExecApprovalRequestEventMsg + | RequestPermissionsEventMsg + | RequestUserInputEventMsg + | DynamicToolCallRequestEventMsg + | DynamicToolCallResponseEventMsg + | ElicitationRequestEventMsg + | ApplyPatchApprovalRequestEventMsg + | DeprecationNoticeEventMsg + | BackgroundEventEventMsg + | UndoStartedEventMsg + | UndoCompletedEventMsg + | StreamErrorEventMsg + | PatchApplyBeginEventMsg + | PatchApplyEndEventMsg + | TurnDiffEventMsg + | GetHistoryEntryResponseEventMsg + | McpListToolsResponseEventMsg + | ListCustomPromptsResponseEventMsg + | ListSkillsResponseEventMsg + | ListRemoteSkillsResponseEventMsg + | RemoteSkillDownloadedEventMsg + | SkillsUpdateAvailableEventMsg + | PlanUpdateEventMsg + | TurnAbortedEventMsg + | ShutdownCompleteEventMsg + | EnteredReviewModeEventMsg + | ExitedReviewModeEventMsg + | RawResponseItemEventMsg + | ItemStartedEventMsg + | ItemCompletedEventMsg + | HookStartedEventMsg + | HookCompletedEventMsg + | AgentMessageContentDeltaEventMsg + | PlanDeltaEventMsg + | ReasoningContentDeltaEventMsg + | ReasoningRawContentDeltaEventMsg + | CollabAgentSpawnBeginEventMsg + | CollabAgentSpawnEndEventMsg + | CollabAgentInteractionBeginEventMsg + | CollabAgentInteractionEndEventMsg + | CollabWaitingBeginEventMsg + | CollabWaitingEndEventMsg + | CollabCloseBeginEventMsg + | CollabCloseEndEventMsg + | CollabResumeBeginEventMsg + | CollabResumeEndEventMsg + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + ErrorEventMsg + | WarningEventMsg + | RealtimeConversationStartedEventMsg + | RealtimeConversationRealtimeEventMsg + | RealtimeConversationClosedEventMsg + | ModelRerouteEventMsg + | ContextCompactedEventMsg + | ThreadRolledBackEventMsg + | TaskStartedEventMsg + | TaskCompleteEventMsg + | TokenCountEventMsg + | AgentMessageEventMsg + | UserMessageEventMsg + | AgentMessageDeltaEventMsg + | AgentReasoningEventMsg + | AgentReasoningDeltaEventMsg + | AgentReasoningRawContentEventMsg + | AgentReasoningRawContentDeltaEventMsg + | AgentReasoningSectionBreakEventMsg + | SessionConfiguredEventMsg + | ThreadNameUpdatedEventMsg + | McpStartupUpdateEventMsg + | McpStartupCompleteEventMsg + | McpToolCallBeginEventMsg + | McpToolCallEndEventMsg + | WebSearchBeginEventMsg + | WebSearchEndEventMsg + | ImageGenerationBeginEventMsg + | ImageGenerationEndEventMsg + | ExecCommandBeginEventMsg + | ExecCommandOutputDeltaEventMsg + | TerminalInteractionEventMsg + | ExecCommandEndEventMsg + | ViewImageToolCallEventMsg + | ExecApprovalRequestEventMsg + | RequestPermissionsEventMsg + | RequestUserInputEventMsg + | DynamicToolCallRequestEventMsg + | DynamicToolCallResponseEventMsg + | ElicitationRequestEventMsg + | ApplyPatchApprovalRequestEventMsg + | DeprecationNoticeEventMsg + | BackgroundEventEventMsg + | UndoStartedEventMsg + | UndoCompletedEventMsg + | StreamErrorEventMsg + | PatchApplyBeginEventMsg + | PatchApplyEndEventMsg + | TurnDiffEventMsg + | GetHistoryEntryResponseEventMsg + | McpListToolsResponseEventMsg + | ListCustomPromptsResponseEventMsg + | ListSkillsResponseEventMsg + | ListRemoteSkillsResponseEventMsg + | RemoteSkillDownloadedEventMsg + | SkillsUpdateAvailableEventMsg + | PlanUpdateEventMsg + | TurnAbortedEventMsg + | ShutdownCompleteEventMsg + | EnteredReviewModeEventMsg + | ExitedReviewModeEventMsg + | RawResponseItemEventMsg + | ItemStartedEventMsg + | ItemCompletedEventMsg + | HookStartedEventMsg + | HookCompletedEventMsg + | AgentMessageContentDeltaEventMsg + | PlanDeltaEventMsg + | ReasoningContentDeltaEventMsg + | ReasoningRawContentDeltaEventMsg + | CollabAgentSpawnBeginEventMsg + | CollabAgentSpawnEndEventMsg + | CollabAgentInteractionBeginEventMsg + | CollabAgentInteractionEndEventMsg + | CollabWaitingBeginEventMsg + | CollabWaitingEndEventMsg + | CollabCloseBeginEventMsg + | CollabCloseEndEventMsg + | CollabResumeBeginEventMsg + | CollabResumeEndEventMsg, + Field( + description="Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", + title="EventMsg", + ), + ] + + +SessionConfiguredEventMsg.model_rebuild() diff --git a/sdk/python/src/codex_app_server/generated/v2_types.py b/sdk/python/src/codex_app_server/generated/v2_types.py new file mode 100644 index 0000000000..932ab438db --- /dev/null +++ b/sdk/python/src/codex_app_server/generated/v2_types.py @@ -0,0 +1,25 @@ +"""Stable aliases over full v2 autogenerated models (datamodel-code-generator).""" + +from .v2_all.ModelListResponse import ModelListResponse +from .v2_all.ThreadCompactStartResponse import ThreadCompactStartResponse +from .v2_all.ThreadListResponse import ThreadListResponse +from .v2_all.ThreadReadResponse import ThreadReadResponse +from .v2_all.ThreadTokenUsageUpdatedNotification import ( + ThreadTokenUsageUpdatedNotification, +) +from .v2_all.TurnCompletedNotification import ThreadItem153 as ThreadItem +from .v2_all.TurnCompletedNotification import ( + TurnCompletedNotification as TurnCompletedNotificationPayload, +) +from .v2_all.TurnSteerResponse import TurnSteerResponse + +__all__ = [ + "ModelListResponse", + "ThreadCompactStartResponse", + "ThreadListResponse", + "ThreadReadResponse", + "ThreadTokenUsageUpdatedNotification", + "TurnCompletedNotificationPayload", + "TurnSteerResponse", + "ThreadItem", +] diff --git a/sdk/python/src/codex_app_server/models.py b/sdk/python/src/codex_app_server/models.py new file mode 100644 index 0000000000..7c5bb34de8 --- /dev/null +++ b/sdk/python/src/codex_app_server/models.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeAlias + +from pydantic import BaseModel + +from .generated.v2_all import ( + AccountLoginCompletedNotification, + AccountRateLimitsUpdatedNotification, + AccountUpdatedNotification, + AgentMessageDeltaNotification, + AppListUpdatedNotification, + CommandExecutionOutputDeltaNotification, + ConfigWarningNotification, + ContextCompactedNotification, + DeprecationNoticeNotification, + ErrorNotification, + FileChangeOutputDeltaNotification, + ItemCompletedNotification, + ItemStartedNotification, + McpServerOauthLoginCompletedNotification, + McpToolCallProgressNotification, + PlanDeltaNotification, + RawResponseItemCompletedNotification, + ReasoningSummaryPartAddedNotification, + ReasoningSummaryTextDeltaNotification, + ReasoningTextDeltaNotification, + TerminalInteractionNotification, + ThreadNameUpdatedNotification, + ThreadStartedNotification, + ThreadTokenUsageUpdatedNotification, + TurnCompletedNotification, + TurnDiffUpdatedNotification, + TurnPlanUpdatedNotification, + TurnStartedNotification, + WindowsWorldWritableWarningNotification, +) + +JsonScalar: TypeAlias = str | int | float | bool | None +JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"] +JsonObject: TypeAlias = dict[str, JsonValue] + + +@dataclass(slots=True) +class UnknownNotification: + params: JsonObject + + +NotificationPayload: TypeAlias = ( + AccountLoginCompletedNotification + | AccountRateLimitsUpdatedNotification + | AccountUpdatedNotification + | AgentMessageDeltaNotification + | AppListUpdatedNotification + | CommandExecutionOutputDeltaNotification + | ConfigWarningNotification + | ContextCompactedNotification + | DeprecationNoticeNotification + | ErrorNotification + | FileChangeOutputDeltaNotification + | ItemCompletedNotification + | ItemStartedNotification + | McpServerOauthLoginCompletedNotification + | McpToolCallProgressNotification + | PlanDeltaNotification + | RawResponseItemCompletedNotification + | ReasoningSummaryPartAddedNotification + | ReasoningSummaryTextDeltaNotification + | ReasoningTextDeltaNotification + | TerminalInteractionNotification + | ThreadNameUpdatedNotification + | ThreadStartedNotification + | ThreadTokenUsageUpdatedNotification + | TurnCompletedNotification + | TurnDiffUpdatedNotification + | TurnPlanUpdatedNotification + | TurnStartedNotification + | WindowsWorldWritableWarningNotification + | UnknownNotification +) + + +@dataclass(slots=True) +class Notification: + method: str + payload: NotificationPayload + + +class ServerInfo(BaseModel): + name: str | None = None + version: str | None = None + + +class InitializeResponse(BaseModel): + serverInfo: ServerInfo | None = None + userAgent: str | None = None diff --git a/sdk/python/src/codex_app_server/py.typed b/sdk/python/src/codex_app_server/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdk/python/src/codex_app_server/retry.py b/sdk/python/src/codex_app_server/retry.py new file mode 100644 index 0000000000..b7e4f77403 --- /dev/null +++ b/sdk/python/src/codex_app_server/retry.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import random +import time +from typing import Callable, TypeVar + +from .errors import is_retryable_error + +T = TypeVar("T") + + +def retry_on_overload( + op: Callable[[], T], + *, + max_attempts: int = 3, + initial_delay_s: float = 0.25, + max_delay_s: float = 2.0, + jitter_ratio: float = 0.2, +) -> T: + """Retry helper for transient server-overload errors.""" + + if max_attempts < 1: + raise ValueError("max_attempts must be >= 1") + + delay = initial_delay_s + attempt = 0 + while True: + attempt += 1 + try: + return op() + except Exception as exc: + if attempt >= max_attempts: + raise + if not is_retryable_error(exc): + raise + + jitter = delay * jitter_ratio + sleep_for = min(max_delay_s, delay) + random.uniform(-jitter, jitter) + if sleep_for > 0: + time.sleep(sleep_for) + delay = min(max_delay_s, delay * 2) diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py new file mode 100644 index 0000000000..f23f55e4dd --- /dev/null +++ b/sdk/python/tests/conftest.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" + +src_str = str(SRC) +if src_str in sys.path: + sys.path.remove(src_str) +sys.path.insert(0, src_str) + +for module_name in list(sys.modules): + if module_name == "codex_app_server" or module_name.startswith("codex_app_server."): + sys.modules.pop(module_name) diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py new file mode 100644 index 0000000000..90446451d1 --- /dev/null +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import ast +import importlib.util +import json +import sys +import tomllib +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def _load_update_script_module(): + script_path = ROOT / "scripts" / "update_sdk_artifacts.py" + spec = importlib.util.spec_from_file_location("update_sdk_artifacts", script_path) + if spec is None or spec.loader is None: + raise AssertionError(f"Failed to load script module: {script_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_generation_has_single_maintenance_entrypoint_script() -> None: + scripts = sorted(p.name for p in (ROOT / "scripts").glob("*.py")) + assert scripts == ["update_sdk_artifacts.py"] + + +def test_generate_types_wires_all_generation_steps() -> None: + source = (ROOT / "scripts" / "update_sdk_artifacts.py").read_text() + tree = ast.parse(source) + + generate_types_fn = next( + ( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "generate_types" + ), + None, + ) + assert generate_types_fn is not None + + calls: list[str] = [] + for node in generate_types_fn.body: + if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call): + fn = node.value.func + if isinstance(fn, ast.Name): + calls.append(fn.id) + + assert calls == [ + "generate_v2_all", + "generate_notification_registry", + "generate_public_api_flat_methods", + ] + + +def test_schema_normalization_only_flattens_string_literal_oneofs() -> None: + script = _load_update_script_module() + schema = json.loads( + ( + ROOT.parent.parent + / "codex-rs" + / "app-server-protocol" + / "schema" + / "json" + / "codex_app_server_protocol.v2.schemas.json" + ).read_text() + ) + + definitions = schema["definitions"] + flattened = [ + name + for name, definition in definitions.items() + if isinstance(definition, dict) + and script._flatten_string_enum_one_of(definition.copy()) + ] + + assert flattened == [ + "AuthMode", + "CommandExecOutputStream", + "ExperimentalFeatureStage", + "InputModality", + "MessagePhase", + ] + + +def test_python_codegen_schema_annotation_adds_stable_variant_titles() -> None: + script = _load_update_script_module() + schema = json.loads( + ( + ROOT.parent.parent + / "codex-rs" + / "app-server-protocol" + / "schema" + / "json" + / "codex_app_server_protocol.v2.schemas.json" + ).read_text() + ) + + script._annotate_schema(schema) + definitions = schema["definitions"] + + server_notification_titles = { + variant.get("title") + for variant in definitions["ServerNotification"]["oneOf"] + if isinstance(variant, dict) + } + assert "ErrorServerNotification" in server_notification_titles + assert "ThreadStartedServerNotification" in server_notification_titles + assert "ErrorNotification" not in server_notification_titles + assert "Thread/startedNotification" not in server_notification_titles + + ask_for_approval_titles = [ + variant.get("title") for variant in definitions["AskForApproval"]["oneOf"] + ] + assert ask_for_approval_titles == [ + "AskForApprovalValue", + "RejectAskForApproval", + ] + + reasoning_summary_titles = [ + variant.get("title") for variant in definitions["ReasoningSummary"]["oneOf"] + ] + assert reasoning_summary_titles == [ + "ReasoningSummaryValue", + "NoneReasoningSummary", + ] + + +def test_generate_v2_all_uses_titles_for_generated_names() -> None: + source = (ROOT / "scripts" / "update_sdk_artifacts.py").read_text() + assert "--use-title-as-name" in source + assert "--use-annotated" in source + assert "--formatters" in source + assert "ruff-format" in source + + +def test_runtime_package_template_has_no_checked_in_binaries() -> None: + runtime_root = ROOT.parent / "python-runtime" / "src" / "codex_cli_bin" + assert sorted( + path.name + for path in runtime_root.rglob("*") + if path.is_file() and "__pycache__" not in path.parts + ) == ["__init__.py"] + + +def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() -> None: + pyproject = tomllib.loads( + (ROOT.parent / "python-runtime" / "pyproject.toml").read_text() + ) + hook_source = (ROOT.parent / "python-runtime" / "hatch_build.py").read_text() + hook_tree = ast.parse(hook_source) + initialize_fn = next( + node + for node in ast.walk(hook_tree) + if isinstance(node, ast.FunctionDef) and node.name == "initialize" + ) + + sdist_guard = next( + ( + node + for node in initialize_fn.body + if isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and isinstance(node.test.left, ast.Attribute) + and isinstance(node.test.left.value, ast.Name) + and node.test.left.value.id == "self" + and node.test.left.attr == "target_name" + and len(node.test.ops) == 1 + and isinstance(node.test.ops[0], ast.Eq) + and len(node.test.comparators) == 1 + and isinstance(node.test.comparators[0], ast.Constant) + and node.test.comparators[0].value == "sdist" + ), + None, + ) + build_data_assignments = { + node.targets[0].slice.value: node.value.value + for node in initialize_fn.body + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Subscript) + and isinstance(node.targets[0].value, ast.Name) + and node.targets[0].value.id == "build_data" + and isinstance(node.targets[0].slice, ast.Constant) + and isinstance(node.targets[0].slice.value, str) + and isinstance(node.value, ast.Constant) + } + + assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] == { + "packages": ["src/codex_cli_bin"], + "include": ["src/codex_cli_bin/bin/**"], + "hooks": {"custom": {}}, + } + assert pyproject["tool"]["hatch"]["build"]["targets"]["sdist"] == { + "hooks": {"custom": {}}, + } + assert sdist_guard is not None + assert build_data_assignments == {"pure_python": False, "infer_tag": True} + + +def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) -> None: + script = _load_update_script_module() + fake_binary = tmp_path / script.runtime_binary_name() + fake_binary.write_text("fake codex\n") + + staged = script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "1.2.3", + fake_binary, + ) + + assert staged == tmp_path / "runtime-stage" + assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n" + assert 'version = "1.2.3"' in (staged / "pyproject.toml").read_text() + + +def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> None: + script = _load_update_script_module() + staging_dir = tmp_path / "runtime-stage" + old_file = staging_dir / "stale.txt" + old_file.parent.mkdir(parents=True) + old_file.write_text("stale") + + fake_binary = tmp_path / script.runtime_binary_name() + fake_binary.write_text("fake codex\n") + + staged = script.stage_python_runtime_package( + staging_dir, + "1.2.3", + fake_binary, + ) + + assert staged == staging_dir + assert not old_file.exists() + assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n" + + +def test_stage_sdk_release_injects_exact_runtime_pin(tmp_path: Path) -> None: + script = _load_update_script_module() + staged = script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.2.1", "1.2.3") + + pyproject = (staged / "pyproject.toml").read_text() + assert 'version = "0.2.1"' in pyproject + assert '"codex-cli-bin==1.2.3"' in pyproject + assert not any((staged / "src" / "codex_app_server").glob("bin/**")) + + +def test_stage_sdk_release_replaces_existing_staging_dir(tmp_path: Path) -> None: + script = _load_update_script_module() + staging_dir = tmp_path / "sdk-stage" + old_file = staging_dir / "stale.txt" + old_file.parent.mkdir(parents=True) + old_file.write_text("stale") + + staged = script.stage_python_sdk_package(staging_dir, "0.2.1", "1.2.3") + + assert staged == staging_dir + assert not old_file.exists() + + +def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None: + script = _load_update_script_module() + calls: list[str] = [] + args = script.parse_args( + [ + "stage-sdk", + str(tmp_path / "sdk-stage"), + "--runtime-version", + "1.2.3", + ] + ) + + def fake_generate_types() -> None: + calls.append("generate_types") + + def fake_stage_sdk_package( + _staging_dir: Path, _sdk_version: str, _runtime_version: str + ) -> Path: + calls.append("stage_sdk") + return tmp_path / "sdk-stage" + + def fake_stage_runtime_package( + _staging_dir: Path, _runtime_version: str, _runtime_binary: Path + ) -> Path: + raise AssertionError("runtime staging should not run for stage-sdk") + + def fake_current_sdk_version() -> str: + return "0.2.0" + + ops = script.CliOps( + generate_types=fake_generate_types, + stage_python_sdk_package=fake_stage_sdk_package, + stage_python_runtime_package=fake_stage_runtime_package, + current_sdk_version=fake_current_sdk_version, + ) + + script.run_command(args, ops) + + assert calls == ["generate_types", "stage_sdk"] + + +def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> None: + script = _load_update_script_module() + fake_binary = tmp_path / script.runtime_binary_name() + fake_binary.write_text("fake codex\n") + calls: list[str] = [] + args = script.parse_args( + [ + "stage-runtime", + str(tmp_path / "runtime-stage"), + str(fake_binary), + "--runtime-version", + "1.2.3", + ] + ) + + def fake_generate_types() -> None: + calls.append("generate_types") + + def fake_stage_sdk_package( + _staging_dir: Path, _sdk_version: str, _runtime_version: str + ) -> Path: + raise AssertionError("sdk staging should not run for stage-runtime") + + def fake_stage_runtime_package( + _staging_dir: Path, _runtime_version: str, _runtime_binary: Path + ) -> Path: + calls.append("stage_runtime") + return tmp_path / "runtime-stage" + + def fake_current_sdk_version() -> str: + return "0.2.0" + + ops = script.CliOps( + generate_types=fake_generate_types, + stage_python_sdk_package=fake_stage_sdk_package, + stage_python_runtime_package=fake_stage_runtime_package, + current_sdk_version=fake_current_sdk_version, + ) + + script.run_command(args, ops) + + assert calls == ["stage_runtime"] + + +def test_default_runtime_is_resolved_from_installed_runtime_package( + tmp_path: Path, +) -> None: + from codex_app_server import client as client_module + + fake_binary = tmp_path / ("codex.exe" if client_module.os.name == "nt" else "codex") + fake_binary.write_text("") + ops = client_module.CodexBinResolverOps( + installed_codex_path=lambda: fake_binary, + path_exists=lambda path: path == fake_binary, + ) + + config = client_module.AppServerConfig() + assert config.codex_bin is None + assert client_module.resolve_codex_bin(config, ops) == fake_binary + + +def test_explicit_codex_bin_override_takes_priority(tmp_path: Path) -> None: + from codex_app_server import client as client_module + + explicit_binary = tmp_path / ( + "custom-codex.exe" if client_module.os.name == "nt" else "custom-codex" + ) + explicit_binary.write_text("") + ops = client_module.CodexBinResolverOps( + installed_codex_path=lambda: (_ for _ in ()).throw( + AssertionError("packaged runtime should not be used") + ), + path_exists=lambda path: path == explicit_binary, + ) + + config = client_module.AppServerConfig(codex_bin=str(explicit_binary)) + assert client_module.resolve_codex_bin(config, ops) == explicit_binary + + +def test_missing_runtime_package_requires_explicit_codex_bin() -> None: + from codex_app_server import client as client_module + + ops = client_module.CodexBinResolverOps( + installed_codex_path=lambda: (_ for _ in ()).throw( + FileNotFoundError("missing packaged runtime") + ), + path_exists=lambda _path: False, + ) + + with pytest.raises(FileNotFoundError, match="missing packaged runtime"): + client_module.resolve_codex_bin(client_module.AppServerConfig(), ops) + + +def test_broken_runtime_package_does_not_fall_back() -> None: + from codex_app_server import client as client_module + + ops = client_module.CodexBinResolverOps( + installed_codex_path=lambda: (_ for _ in ()).throw( + FileNotFoundError("missing packaged binary") + ), + path_exists=lambda _path: False, + ) + + with pytest.raises(FileNotFoundError) as exc_info: + client_module.resolve_codex_bin(client_module.AppServerConfig(), ops) + + assert str(exc_info.value) == ("missing packaged binary") diff --git a/sdk/python/tests/test_client_rpc_methods.py b/sdk/python/tests/test_client_rpc_methods.py new file mode 100644 index 0000000000..2742180566 --- /dev/null +++ b/sdk/python/tests/test_client_rpc_methods.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from codex_app_server.client import AppServerClient, _params_dict +from codex_app_server.generated.v2_all import ThreadListParams, ThreadTokenUsageUpdatedNotification +from codex_app_server.models import UnknownNotification + +ROOT = Path(__file__).resolve().parents[1] + + +def test_thread_set_name_and_compact_use_current_rpc_methods() -> None: + client = AppServerClient() + calls: list[tuple[str, dict[str, Any] | None]] = [] + + def fake_request(method: str, params, *, response_model): # type: ignore[no-untyped-def] + calls.append((method, params)) + return response_model.model_validate({}) + + client.request = fake_request # type: ignore[method-assign] + + client.thread_set_name("thread-1", "sdk-name") + client.thread_compact("thread-1") + + assert calls[0][0] == "thread/name/set" + assert calls[1][0] == "thread/compact/start" + + +def test_generated_params_models_are_snake_case_and_dump_by_alias() -> None: + params = ThreadListParams(search_term="needle", limit=5) + + assert "search_term" in ThreadListParams.model_fields + dumped = _params_dict(params) + assert dumped == {"searchTerm": "needle", "limit": 5} + + +def test_generated_v2_bundle_has_single_shared_plan_type_definition() -> None: + source = (ROOT / "src" / "codex_app_server" / "generated" / "v2_all.py").read_text() + assert source.count("class PlanType(") == 1 + + +def test_notifications_are_typed_with_canonical_v2_methods() -> None: + client = AppServerClient() + event = client._coerce_notification( + "thread/tokenUsage/updated", + { + "threadId": "thread-1", + "turnId": "turn-1", + "tokenUsage": { + "last": { + "cachedInputTokens": 0, + "inputTokens": 1, + "outputTokens": 2, + "reasoningOutputTokens": 0, + "totalTokens": 3, + }, + "total": { + "cachedInputTokens": 0, + "inputTokens": 1, + "outputTokens": 2, + "reasoningOutputTokens": 0, + "totalTokens": 3, + }, + }, + }, + ) + + assert event.method == "thread/tokenUsage/updated" + assert isinstance(event.payload, ThreadTokenUsageUpdatedNotification) + assert event.payload.turn_id == "turn-1" + + +def test_unknown_notifications_fall_back_to_unknown_payloads() -> None: + client = AppServerClient() + event = client._coerce_notification( + "unknown/notification", + { + "id": "evt-1", + "conversationId": "thread-1", + "msg": {"type": "turn_aborted"}, + }, + ) + + assert event.method == "unknown/notification" + assert isinstance(event.payload, UnknownNotification) + assert event.payload.params["msg"] == {"type": "turn_aborted"} + + +def test_invalid_notification_payload_falls_back_to_unknown() -> None: + client = AppServerClient() + event = client._coerce_notification("thread/tokenUsage/updated", {"threadId": "missing"}) + + assert event.method == "thread/tokenUsage/updated" + assert isinstance(event.payload, UnknownNotification) diff --git a/sdk/python/tests/test_contract_generation.py b/sdk/python/tests/test_contract_generation.py new file mode 100644 index 0000000000..ae926e4817 --- /dev/null +++ b/sdk/python/tests/test_contract_generation.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GENERATED_TARGETS = [ + Path("src/codex_app_server/generated/notification_registry.py"), + Path("src/codex_app_server/generated/v2_all.py"), + Path("src/codex_app_server/public_api.py"), +] + + +def _snapshot_target(root: Path, rel_path: Path) -> dict[str, bytes] | bytes | None: + target = root / rel_path + if not target.exists(): + return None + if target.is_file(): + return target.read_bytes() + + snapshot: dict[str, bytes] = {} + for path in sorted(target.rglob("*")): + if path.is_file() and "__pycache__" not in path.parts: + snapshot[str(path.relative_to(target))] = path.read_bytes() + return snapshot + + +def _snapshot_targets(root: Path) -> dict[str, dict[str, bytes] | bytes | None]: + return { + str(rel_path): _snapshot_target(root, rel_path) for rel_path in GENERATED_TARGETS + } + + +def test_generated_files_are_up_to_date(): + before = _snapshot_targets(ROOT) + + # Regenerate contract artifacts via single maintenance entrypoint. + env = os.environ.copy() + python_bin = str(Path(sys.executable).parent) + env["PATH"] = f"{python_bin}{os.pathsep}{env.get('PATH', '')}" + + subprocess.run( + [sys.executable, "scripts/update_sdk_artifacts.py", "generate-types"], + cwd=ROOT, + check=True, + env=env, + ) + + after = _snapshot_targets(ROOT) + assert before == after, "Generated files drifted after regeneration"