Merge f9ac5af535 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-05-20 17:27:21 -07:00
committed by GitHub
179 changed files with 4315 additions and 559 deletions
+3
View File
@@ -26,6 +26,9 @@ jobs:
- name: Verify Bazel clippy flags match Cargo workspace lints
run: python3 .github/scripts/verify_bazel_clippy_lints.py
- name: Test Codex package builder
run: python3 -m unittest discover -s scripts/codex_package -p 'test_*.py'
- name: Setup pnpm
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
with:
-1
View File
@@ -1799,7 +1799,6 @@
"winnow_0.7.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"annotate-snippets\",\"req\":\"^0.11.4\"},{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.15\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.100\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"circular\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"is_terminal_polyfill\",\"optional\":true,\"req\":\"^1.48.1\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.6.0\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2.1.1\"},{\"features\":[\"examples\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"term-transcript\",\"req\":\"^0.2.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.3\"}],\"features\":{\"alloc\":[],\"debug\":[\"std\",\"dep:anstream\",\"dep:anstyle\",\"dep:is_terminal_polyfill\",\"dep:terminal_size\"],\"default\":[\"std\"],\"simd\":[\"dep:memchr\"],\"std\":[\"alloc\",\"memchr?/std\"],\"unstable-doc\":[\"alloc\",\"std\",\"simd\",\"unstable-recover\"],\"unstable-recover\":[]}}",
"winreg_0.10.1": "{\"dependencies\":[{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"~3.0\"},{\"features\":[\"impl-default\",\"impl-debug\",\"minwindef\",\"minwinbase\",\"timezoneapi\",\"winerror\",\"winnt\",\"winreg\",\"handleapi\"],\"name\":\"winapi\",\"req\":\"^0.3.9\"}],\"features\":{\"serialization-serde\":[\"transactions\",\"serde\"],\"transactions\":[\"winapi/ktmw32\"]}}",
"winreg_0.50.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"~3.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Time\",\"Win32_System_Registry\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_Diagnostics_Debug\"],\"name\":\"windows-sys\",\"req\":\"^0.48.0\"}],\"features\":{\"serialization-serde\":[\"transactions\",\"serde\"],\"transactions\":[]}}",
"winres_0.1.12": "{\"dependencies\":[{\"name\":\"toml\",\"req\":\"^0.5\"},{\"features\":[\"winnt\"],\"kind\":\"dev\",\"name\":\"winapi\",\"req\":\"^0.3\"}],\"features\":{}}",
"winsafe_0.0.19": "{\"dependencies\":[],\"features\":{\"comctl\":[\"ole\"],\"dshow\":[\"oleaut\"],\"dwm\":[\"uxtheme\"],\"dxgi\":[\"ole\"],\"gdi\":[\"user\"],\"gui\":[\"comctl\",\"shell\",\"uxtheme\"],\"kernel\":[],\"mf\":[\"oleaut\"],\"ole\":[\"user\"],\"oleaut\":[\"ole\"],\"shell\":[\"oleaut\"],\"taskschd\":[\"oleaut\"],\"user\":[\"kernel\"],\"uxtheme\":[\"gdi\",\"ole\"],\"version\":[\"kernel\"]}}",
"winsplit_0.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}",
"wiremock_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-rt\",\"req\":\"^2.10.0\"},{\"name\":\"assert-json-diff\",\"req\":\"^2.0.2\"},{\"features\":[\"attributes\",\"tokio1\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.2\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"deadpool\",\"req\":\"^0.12.2\"},{\"name\":\"futures\",\"req\":\"^0.3.31\"},{\"name\":\"http\",\"req\":\"^1.3\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"full\"],\"name\":\"hyper\",\"req\":\"^1.7\"},{\"features\":[\"tokio\",\"server\",\"http1\",\"http2\"],\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"regex\",\"req\":\"^1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12.23\"},{\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"rt\",\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.47.1\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.47.1\"},{\"name\":\"url\",\"req\":\"^2.5\"}],\"features\":{}}",
+1 -10
View File
@@ -2434,6 +2434,7 @@ dependencies = [
"dunce",
"futures",
"gethostname",
"indexmap 2.13.0",
"libc",
"multimap",
"pretty_assertions",
@@ -4142,7 +4143,6 @@ dependencies = [
"tokio",
"windows 0.58.0",
"windows-sys 0.52.0",
"winres",
]
[[package]]
@@ -14727,15 +14727,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "winres"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [
"toml 0.5.11",
]
[[package]]
name = "winsafe"
version = "0.0.19"
@@ -3628,6 +3628,7 @@ async fn turn_event_counts_completed_tool_items() {
status: McpToolCallStatus::Completed,
arguments: json!({}),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: None,
duration_ms: Some(2),
+1
View File
@@ -991,6 +991,7 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str {
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
HookEventName::Stop => "Stop",
}
}
@@ -3156,6 +3156,62 @@
],
"type": "object"
},
"ThreadGoalClearParams": {
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadGoalGetParams": {
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadGoalSetParams": {
"properties": {
"objective": {
"type": [
"string",
"null"
]
},
"status": {
"anyOf": [
{
"$ref": "#/definitions/ThreadGoalStatus"
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
},
"tokenBudget": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadGoalStatus": {
"enum": [
"active",
@@ -4316,6 +4372,78 @@
"title": "Thread/name/setRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/goal/set"
],
"title": "Thread/goal/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadGoalSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/goal/setRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/goal/get"
],
"title": "Thread/goal/getRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadGoalGetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/goal/getRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/goal/clear"
],
"title": "Thread/goal/clearRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadGoalClearParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/goal/clearRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1836,6 +1836,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -3860,6 +3861,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -372,6 +372,78 @@
"title": "Thread/name/setRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/goal/set"
],
"title": "Thread/goal/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadGoalSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/goal/setRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/goal/get"
],
"title": "Thread/goal/getRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadGoalGetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/goal/getRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/goal/clear"
],
"title": "Thread/goal/clearRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadGoalClearParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/goal/clearRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -9710,6 +9782,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -10606,6 +10679,12 @@
},
"type": "array"
},
"SubagentStop": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
@@ -10634,6 +10713,7 @@
"SessionStart",
"Stop",
"SubagentStart",
"SubagentStop",
"UserPromptSubmit"
],
"type": "object"
@@ -11266,6 +11346,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/v2/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -15746,6 +15834,32 @@
],
"type": "object"
},
"ThreadGoalClearParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadGoalClearParams",
"type": "object"
},
"ThreadGoalClearResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cleared": {
"type": "boolean"
}
},
"required": [
"cleared"
],
"title": "ThreadGoalClearResponse",
"type": "object"
},
"ThreadGoalClearedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -15759,6 +15873,85 @@
"title": "ThreadGoalClearedNotification",
"type": "object"
},
"ThreadGoalGetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadGoalGetParams",
"type": "object"
},
"ThreadGoalGetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"goal": {
"anyOf": [
{
"$ref": "#/definitions/v2/ThreadGoal"
},
{
"type": "null"
}
]
}
},
"title": "ThreadGoalGetResponse",
"type": "object"
},
"ThreadGoalSetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"objective": {
"type": [
"string",
"null"
]
},
"status": {
"anyOf": [
{
"$ref": "#/definitions/v2/ThreadGoalStatus"
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
},
"tokenBudget": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
"threadId"
],
"title": "ThreadGoalSetParams",
"type": "object"
},
"ThreadGoalSetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"goal": {
"$ref": "#/definitions/v2/ThreadGoal"
}
},
"required": [
"goal"
],
"title": "ThreadGoalSetResponse",
"type": "object"
},
"ThreadGoalStatus": {
"enum": [
"active",
@@ -16126,6 +16319,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1098,6 +1098,78 @@
"title": "Thread/name/setRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/goal/set"
],
"title": "Thread/goal/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadGoalSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/goal/setRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/goal/get"
],
"title": "Thread/goal/getRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadGoalGetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/goal/getRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/goal/clear"
],
"title": "Thread/goal/clearRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadGoalClearParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/goal/clearRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -6190,6 +6262,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -7135,6 +7208,12 @@
},
"type": "array"
},
"SubagentStop": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -7163,6 +7242,7 @@
"SessionStart",
"Stop",
"SubagentStart",
"SubagentStop",
"UserPromptSubmit"
],
"type": "object"
@@ -7795,6 +7875,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -13570,6 +13658,32 @@
],
"type": "object"
},
"ThreadGoalClearParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadGoalClearParams",
"type": "object"
},
"ThreadGoalClearResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cleared": {
"type": "boolean"
}
},
"required": [
"cleared"
],
"title": "ThreadGoalClearResponse",
"type": "object"
},
"ThreadGoalClearedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -13583,6 +13697,85 @@
"title": "ThreadGoalClearedNotification",
"type": "object"
},
"ThreadGoalGetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadGoalGetParams",
"type": "object"
},
"ThreadGoalGetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"goal": {
"anyOf": [
{
"$ref": "#/definitions/ThreadGoal"
},
{
"type": "null"
}
]
}
},
"title": "ThreadGoalGetResponse",
"type": "object"
},
"ThreadGoalSetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"objective": {
"type": [
"string",
"null"
]
},
"status": {
"anyOf": [
{
"$ref": "#/definitions/ThreadGoalStatus"
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
},
"tokenBudget": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
"threadId"
],
"title": "ThreadGoalSetParams",
"type": "object"
},
"ThreadGoalSetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"goal": {
"$ref": "#/definitions/ThreadGoal"
}
},
"required": [
"goal"
],
"title": "ThreadGoalSetResponse",
"type": "object"
},
"ThreadGoalStatus": {
"enum": [
"active",
@@ -13950,6 +14143,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -288,6 +288,12 @@
},
"type": "array"
},
"SubagentStop": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -316,6 +322,7 @@
"SessionStart",
"Stop",
"SubagentStart",
"SubagentStop",
"UserPromptSubmit"
],
"type": "object"
@@ -15,6 +15,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -15,6 +15,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -30,6 +30,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -800,6 +800,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -800,6 +800,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -43,6 +43,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -47,6 +47,7 @@
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1421,6 +1421,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadGoalClearParams",
"type": "object"
}
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cleared": {
"type": "boolean"
}
},
"required": [
"cleared"
],
"title": "ThreadGoalClearResponse",
"type": "object"
}
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadGoalGetParams",
"type": "object"
}
@@ -0,0 +1,76 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ThreadGoal": {
"properties": {
"createdAt": {
"format": "int64",
"type": "integer"
},
"objective": {
"type": "string"
},
"status": {
"$ref": "#/definitions/ThreadGoalStatus"
},
"threadId": {
"type": "string"
},
"timeUsedSeconds": {
"format": "int64",
"type": "integer"
},
"tokenBudget": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"tokensUsed": {
"format": "int64",
"type": "integer"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"createdAt",
"objective",
"status",
"threadId",
"timeUsedSeconds",
"tokensUsed",
"updatedAt"
],
"type": "object"
},
"ThreadGoalStatus": {
"enum": [
"active",
"paused",
"blocked",
"usageLimited",
"budgetLimited",
"complete"
],
"type": "string"
}
},
"properties": {
"goal": {
"anyOf": [
{
"$ref": "#/definitions/ThreadGoal"
},
{
"type": "null"
}
]
}
},
"title": "ThreadGoalGetResponse",
"type": "object"
}
@@ -0,0 +1,49 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ThreadGoalStatus": {
"enum": [
"active",
"paused",
"blocked",
"usageLimited",
"budgetLimited",
"complete"
],
"type": "string"
}
},
"properties": {
"objective": {
"type": [
"string",
"null"
]
},
"status": {
"anyOf": [
{
"$ref": "#/definitions/ThreadGoalStatus"
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
},
"tokenBudget": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
"threadId"
],
"title": "ThreadGoalSetParams",
"type": "object"
}
@@ -0,0 +1,72 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ThreadGoal": {
"properties": {
"createdAt": {
"format": "int64",
"type": "integer"
},
"objective": {
"type": "string"
},
"status": {
"$ref": "#/definitions/ThreadGoalStatus"
},
"threadId": {
"type": "string"
},
"timeUsedSeconds": {
"format": "int64",
"type": "integer"
},
"tokenBudget": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"tokensUsed": {
"format": "int64",
"type": "integer"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"createdAt",
"objective",
"status",
"threadId",
"timeUsedSeconds",
"tokensUsed",
"updatedAt"
],
"type": "object"
},
"ThreadGoalStatus": {
"enum": [
"active",
"paused",
"blocked",
"usageLimited",
"budgetLimited",
"complete"
],
"type": "string"
}
},
"properties": {
"goal": {
"$ref": "#/definitions/ThreadGoal"
}
},
"required": [
"goal"
],
"title": "ThreadGoalSetResponse",
"type": "object"
}
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1421,6 +1421,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1421,6 +1421,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
File diff suppressed because one or more lines are too long
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "stop";
export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop";
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup";
export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };
export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };
+5 -1
View File
@@ -12,4 +12,8 @@ export type Model = { id: string, model: string, upgrade: string | null, upgrade
/**
* Deprecated: use `serviceTiers` instead.
*/
additionalSpeedTiers: Array<string>, serviceTiers: Array<ModelServiceTier>, isDefault: boolean, };
additionalSpeedTiers: Array<string>, serviceTiers: Array<ModelServiceTier>,
/**
* Catalog default service tier id for this model, when one is configured.
*/
defaultServiceTier: string | null, isDefault: boolean, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadGoalClearParams = { threadId: string, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadGoalClearResponse = { cleared: boolean, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadGoalGetParams = { threadId: string, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadGoal } from "./ThreadGoal";
export type ThreadGoalGetResponse = { goal: ThreadGoal | null, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadGoalStatus } from "./ThreadGoalStatus";
export type ThreadGoalSetParams = { threadId: string, objective?: string | null, status?: ThreadGoalStatus | null, tokenBudget?: number | null, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadGoal } from "./ThreadGoal";
export type ThreadGoalSetResponse = { goal: ThreadGoal, };
@@ -53,7 +53,7 @@ exitCode: number | null,
/**
* The duration of the command execution in milliseconds.
*/
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, result: McpToolCallResult | null, error: McpToolCallError | null,
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, pluginId: string | null, result: McpToolCallResult | null, error: McpToolCallError | null,
/**
* The duration of the MCP tool call in milliseconds.
*/
@@ -365,7 +365,13 @@ export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse";
export type { ThreadForkParams } from "./ThreadForkParams";
export type { ThreadForkResponse } from "./ThreadForkResponse";
export type { ThreadGoal } from "./ThreadGoal";
export type { ThreadGoalClearParams } from "./ThreadGoalClearParams";
export type { ThreadGoalClearResponse } from "./ThreadGoalClearResponse";
export type { ThreadGoalClearedNotification } from "./ThreadGoalClearedNotification";
export type { ThreadGoalGetParams } from "./ThreadGoalGetParams";
export type { ThreadGoalGetResponse } from "./ThreadGoalGetResponse";
export type { ThreadGoalSetParams } from "./ThreadGoalSetParams";
export type { ThreadGoalSetResponse } from "./ThreadGoalSetResponse";
export type { ThreadGoalStatus } from "./ThreadGoalStatus";
export type { ThreadGoalUpdatedNotification } from "./ThreadGoalUpdatedNotification";
export type { ThreadInjectItemsParams } from "./ThreadInjectItemsParams";
@@ -494,19 +494,16 @@ client_request_definitions! {
serialization: thread_id(params.thread_id),
response: v2::ThreadSetNameResponse,
},
#[experimental("thread/goal/set")]
ThreadGoalSet => "thread/goal/set" {
params: v2::ThreadGoalSetParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadGoalSetResponse,
},
#[experimental("thread/goal/get")]
ThreadGoalGet => "thread/goal/get" {
params: v2::ThreadGoalGetParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadGoalGetResponse,
},
#[experimental("thread/goal/clear")]
ThreadGoalClear => "thread/goal/clear" {
params: v2::ThreadGoalClearParams,
serialization: thread_id(params.thread_id),
@@ -1473,9 +1470,7 @@ server_notification_definitions! {
ThreadClosed => "thread/closed" (v2::ThreadClosedNotification),
SkillsChanged => "skills/changed" (v2::SkillsChangedNotification),
ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification),
#[experimental("thread/goal/updated")]
ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification),
#[experimental("thread/goal/cleared")]
ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification),
#[experimental("thread/settings/updated")]
ThreadSettingsUpdated => "thread/settings/updated" (v2::ThreadSettingsUpdatedNotification),
@@ -3035,7 +3030,7 @@ mod tests {
}
#[test]
fn thread_goal_methods_are_marked_experimental() {
fn thread_goal_methods_are_not_marked_experimental() {
let set_request = ClientRequest::ThreadGoalSet {
request_id: RequestId::Integer(1),
params: v2::ThreadGoalSetParams {
@@ -3060,20 +3055,20 @@ mod tests {
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&set_request),
Some("thread/goal/set")
None
);
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&get_request),
Some("thread/goal/get")
None
);
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&clear_request),
Some("thread/goal/clear")
None
);
}
#[test]
fn thread_goal_notifications_are_marked_experimental() {
fn thread_goal_notifications_are_not_marked_experimental() {
let goal = v2::ThreadGoal {
thread_id: "thr_123".to_string(),
objective: "ship goal mode".to_string(),
@@ -3095,11 +3090,11 @@ mod tests {
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&updated),
Some("thread/goal/updated")
None
);
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&cleared),
Some("thread/goal/cleared")
None
);
}
@@ -525,6 +525,7 @@ impl ThreadHistoryBuilder {
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
result: None,
error: None,
duration_ms: None,
@@ -566,6 +567,7 @@ impl ThreadHistoryBuilder {
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
result,
error,
duration_ms,
@@ -1911,6 +1913,7 @@ mod tests {
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: None,
plugin_id: None,
duration: Duration::from_millis(8),
result: Err("boom".into()),
}),
@@ -1960,6 +1963,7 @@ mod tests {
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"id":"123"}),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: Some(McpToolCallError {
message: "boom".into(),
@@ -1986,6 +1990,7 @@ mod tests {
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
plugin_id: Some("sample@test".into()),
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
content: vec![serde_json::json!({
@@ -2016,6 +2021,7 @@ mod tests {
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"id":"123"}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
plugin_id: Some("sample@test".into()),
result: Some(Box::new(McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
@@ -434,6 +434,9 @@ pub struct ManagedHooksRequirements {
#[serde(rename = "SubagentStart")]
#[ts(rename = "SubagentStart")]
pub subagent_start: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "SubagentStop")]
#[ts(rename = "SubagentStop")]
pub subagent_stop: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "Stop")]
#[ts(rename = "Stop")]
pub stop: Vec<ConfiguredHookMatcherGroup>,
@@ -17,7 +17,7 @@ use ts_rs::TS;
v2_enum_from_core!(
pub enum HookEventName from CoreHookEventName {
PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, Stop
PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, SubagentStop, Stop
}
);
@@ -286,6 +286,7 @@ pub enum ThreadItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
mcp_app_resource_uri: Option<String>,
plugin_id: Option<String>,
result: Option<Box<McpToolCallResult>>,
error: Option<McpToolCallError>,
/// The duration of the MCP tool call in milliseconds.
@@ -846,6 +847,7 @@ impl From<CoreTurnItem> for ThreadItem {
status: McpToolCallStatus::from(mcp.status),
arguments: mcp.arguments,
mcp_app_resource_uri: mcp.mcp_app_resource_uri,
plugin_id: mcp.plugin_id,
result: mcp.result.map(McpToolCallResult::from).map(Box::new),
error: mcp.error.map(McpToolCallError::from),
duration_ms,
@@ -98,6 +98,9 @@ pub struct Model {
pub additional_speed_tiers: Vec<String>,
#[serde(default)]
pub service_tiers: Vec<ModelServiceTier>,
/// Catalog default service tier id for this model, when one is configured.
#[serde(default)]
pub default_service_tier: Option<String>,
// Only one model should be marked as default.
pub is_default: bool,
}
@@ -2491,6 +2491,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
tool: "tool".to_string(),
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: Some("sample@test".to_string()),
status: CoreMcpToolCallStatus::InProgress,
result: None,
error: None,
@@ -2506,6 +2507,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
status: McpToolCallStatus::InProgress,
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: Some("sample@test".to_string()),
result: None,
error: None,
duration_ms: None,
@@ -2518,6 +2520,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
tool: "tool".to_string(),
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
plugin_id: None,
status: CoreMcpToolCallStatus::Completed,
result: Some(CallToolResult {
content: vec![json!({"type": "text", "text": "ok"})],
@@ -2538,6 +2541,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
status: McpToolCallStatus::Completed,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(Box::new(McpToolCallResult {
content: vec![json!({"type": "text", "text": "ok"})],
structured_content: Some(json!({"ok": true})),
+2 -2
View File
@@ -188,7 +188,7 @@ Example with notification opt-out:
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
@@ -1231,7 +1231,7 @@ Today both notifications carry an empty `items` array even when item events were
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`.
- `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`.
- `mcpToolCall` — `{id, server, tool, status, arguments, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`.
- `mcpToolCall` — `{id, server, tool, status, arguments, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`.
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
- `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion.
- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool.
@@ -696,10 +696,25 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
"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"
);
let config: TomlValue =
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
CI = "false"
FOO = "bar"
MAX_RETRIES = "3"
MY_TEAM = "codex"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
assert_eq!(
fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md"))
.expect("read copied skill"),
@@ -732,10 +747,24 @@ async fn import_home_config_uses_local_settings_over_project_settings() {
.await
.expect("import");
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]\nFOO = \"local\"\nLOCAL_ONLY = \"true\"\nPROJECT_ONLY = \"yes\"\n"
);
let config: TomlValue =
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
FOO = "local"
LOCAL_ONLY = "true"
PROJECT_ONLY = "yes"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
}
#[tokio::test]
+1
View File
@@ -53,6 +53,7 @@ fn model_from_preset(preset: ModelPreset) -> Model {
description: service_tier.description,
})
.collect(),
default_service_tier: preset.default_service_tier,
is_default: preset.is_default,
}
}
@@ -467,6 +467,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
session_start,
user_prompt_submit,
subagent_start,
subagent_stop,
stop,
} = hooks;
@@ -481,6 +482,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
session_start: map_hook_matcher_groups_to_api(session_start),
user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit),
subagent_start: map_hook_matcher_groups_to_api(subagent_start),
subagent_stop: map_hook_matcher_groups_to_api(subagent_stop),
stop: map_hook_matcher_groups_to_api(stop),
}
}
@@ -79,6 +79,7 @@ mod tests {
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"secret":"argument"}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
result: Some(Box::new(McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
@@ -120,6 +121,7 @@ mod tests {
status: McpToolCallStatus::Completed,
arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
result: Some(Box::new(redacted_mcp_tool_call_result())),
error: None,
duration_ms: Some(8),
@@ -136,6 +138,7 @@ mod tests {
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"secret":"argument"}),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: Some(McpToolCallError {
message: "secret error".to_string(),
@@ -154,6 +157,7 @@ mod tests {
status: McpToolCallStatus::Failed,
arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: Some(McpToolCallError {
message: REDACTED_PAYLOAD.to_string(),
+9 -19
View File
@@ -2,9 +2,8 @@ use super::*;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_app_server_protocol::ThreadRealtimeStartedNotification;
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -15,20 +14,11 @@ fn absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::from_absolute_path(path).expect("absolute path")
}
fn thread_goal_updated_notification() -> ServerNotification {
ServerNotification::ThreadGoalUpdated(ThreadGoalUpdatedNotification {
fn thread_realtime_started_notification() -> ServerNotification {
ServerNotification::ThreadRealtimeStarted(ThreadRealtimeStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: ThreadGoal {
thread_id: "thread-1".to_string(),
objective: "ship goal mode".to_string(),
status: ThreadGoalStatus::Active,
token_budget: None,
tokens_used: 0,
time_used_seconds: 0,
created_at: 1,
updated_at: 1,
},
realtime_session_id: None,
version: RealtimeConversationVersion::V1,
})
}
@@ -182,7 +172,7 @@ async fn experimental_notifications_are_dropped_without_capability() {
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()),
message: OutgoingMessage::AppServerNotification(thread_realtime_started_notification()),
write_complete_tx: None,
},
)
@@ -215,7 +205,7 @@ async fn experimental_notifications_are_preserved_with_capability() {
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()),
message: OutgoingMessage::AppServerNotification(thread_realtime_started_notification()),
write_complete_tx: None,
},
)
@@ -227,7 +217,7 @@ async fn experimental_notifications_are_preserved_with_capability() {
.expect("experimental notification should reach opted-in client");
assert!(matches!(
message.message,
OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated(_))
OutgoingMessage::AppServerNotification(ServerNotification::ThreadRealtimeStarted(_))
));
}
@@ -30,6 +30,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
priority,
additional_speed_tiers: preset.additional_speed_tiers.clone(),
service_tiers: preset.service_tiers.clone(),
default_service_tier: preset.default_service_tier.clone(),
upgrade: preset.upgrade.as_ref().map(Into::into),
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -513,6 +513,7 @@ url = "{mcp_server_url}/mcp"
status,
arguments: json!({ "message": LARGE_RESPONSE_MESSAGE }),
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(result),
error: None,
duration_ms: None,
@@ -69,6 +69,7 @@ fn model_from_preset(preset: &ModelPreset) -> Model {
description: service_tier.description.clone(),
})
.collect(),
default_service_tier: preset.default_service_tier.clone(),
is_default: preset.is_default,
}
}
@@ -679,6 +679,7 @@ fn append_resume_redaction_history(
arguments: Some(json!({"secret":"argument"})),
},
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: None,
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
content: vec![json!({
@@ -22,6 +22,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::test_support::all_model_presets;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -136,7 +137,7 @@ async fn thread_settings_update_while_turn_is_active_emits_notification() -> Res
}
#[tokio::test]
async fn thread_settings_update_clears_service_tier() -> Result<()> {
async fn thread_settings_update_null_service_tier_uses_default() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(vec![
create_final_assistant_message_sse_response("done")?,
])
@@ -181,7 +182,10 @@ async fn thread_settings_update_clears_service_tier() -> Result<()> {
let clear_updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(clear_updated.thread_id, thread.id);
assert_eq!(clear_updated.thread_settings.model, model_id);
assert_eq!(clear_updated.thread_settings.service_tier, None);
assert_eq!(
clear_updated.thread_settings.service_tier.as_deref(),
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE)
);
start_text_turn(&mut mcp, thread.id).await?;
timeout(
@@ -28,6 +28,7 @@ use codex_core::config::set_project_trust_level;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
@@ -484,7 +485,7 @@ model_reasoning_effort = "high"
}
#[tokio::test]
async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> {
async fn thread_start_drops_unsupported_service_tier_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
@@ -508,7 +509,39 @@ async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> {
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(service_tier, Some(service_tier_id));
// Unsupported catalog ids are dropped at session config time instead of echoed back.
assert_eq!(service_tier, None);
Ok(())
}
#[tokio::test]
async fn thread_start_accepts_default_service_tier() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
service_tier: Some(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())),
..Default::default()
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(
service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
Ok(())
}
@@ -1004,10 +1004,14 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn()
assert!(
err.error
.message
.contains("invalid thread settings override")
.contains("`approval_policy = \"never\"` cannot be used"),
"unexpected error message: {}",
err.error.message
);
assert!(
err.error.message.contains("allowed set [ReadOnly]"),
err.error
.message
.contains("requirements do not allow `sandbox_mode = \"danger-full-access\"`"),
"unexpected error message: {}",
err.error.message
);
+70 -31
View File
@@ -6,14 +6,15 @@ use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core_plugins::PluginMarketplaceUpgradeOutcome;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root;
use codex_core_plugins::marketplace::marketplace_root_dir;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest;
use codex_core_plugins::marketplace_remove::remove_marketplace;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
use std::collections::HashSet;
use crate::plugin_cmd::configured_marketplace_snapshot_issues;
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin marketplace")]
@@ -30,7 +31,7 @@ enum MarketplaceSubcommand {
/// Add a local or Git marketplace to the configured marketplace sources.
Add(AddMarketplaceArgs),
/// List configured marketplace names and their local snapshot roots.
/// List plugin marketplaces Codex is currently considering and their roots.
List,
/// Refresh configured Git marketplace snapshots.
@@ -150,39 +151,77 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> {
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let configured_marketplaces = config
.config_layer_stack
.get_active_user_layer()
.and_then(|layer| layer.config.get("marketplaces"))
.and_then(toml::Value::as_table);
let Some(configured_marketplaces) = configured_marketplaces else {
println!("No configured plugin marketplaces.");
return Ok(());
};
if configured_marketplaces.is_empty() {
println!("No configured plugin marketplaces.");
let manager = PluginsManager::new(config.codex_home.to_path_buf());
let plugins_input = config.plugins_config_input();
let marketplace_listing = manager
.discover_marketplaces_for_config(&plugins_input, &[])
.context("failed to list plugin marketplaces")?;
let mut load_issues = configured_marketplace_snapshot_issues(
config.codex_home.as_path(),
&plugins_input,
&marketplace_listing.errors,
/*marketplace_name*/ None,
);
let mut issue_paths = load_issues
.iter()
.map(|issue| issue.path.clone())
.collect::<HashSet<_>>();
for error in &marketplace_listing.errors {
if issue_paths.insert(error.path.to_path_buf()) {
load_issues.push(crate::plugin_cmd::ConfiguredMarketplaceSnapshotIssue {
marketplace_name: error.path.display().to_string(),
path: error.path.to_path_buf(),
message: error.message.clone(),
});
}
}
if !load_issues.is_empty() {
let issue_lines = load_issues
.iter()
.map(|issue| {
format!(
"- `{}` at {}: {}",
issue.marketplace_name,
issue.path.display(),
issue.message
)
})
.collect::<Vec<_>>()
.join("\n");
bail!("failed to load marketplace(s):\n{issue_lines}");
}
let marketplaces = marketplace_listing.marketplaces;
if marketplaces.is_empty() {
println!("No plugin marketplaces in scope.");
return Ok(());
}
let default_install_root = marketplace_install_root(config.codex_home.as_path());
for (marketplace_name, marketplace) in configured_marketplaces {
if !marketplace.is_table() {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: expected table.");
let mut seen_roots = HashSet::new();
let mut rows = Vec::new();
for marketplace in marketplaces {
let Ok(root) = marketplace_root_dir(&marketplace.path) else {
continue;
};
if !seen_roots.insert(root.clone()) {
continue;
}
if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: {err}.");
continue;
}
let root = resolve_configured_marketplace_root(
rows.push((marketplace.name, root));
}
let marketplace_width = rows
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or("MARKETPLACE".len())
.max("MARKETPLACE".len());
println!("{:<marketplace_width$} ROOT", "MARKETPLACE");
for (marketplace_name, root) in rows {
println!(
"{:<marketplace_width$} {}",
marketplace_name,
marketplace,
default_install_root.as_path(),
)
.map(|root| root.display().to_string())
.unwrap_or_else(|| "<invalid source>".to_string());
println!("{marketplace_name}\t{root}");
root.display()
);
}
Ok(())
+138 -19
View File
@@ -5,6 +5,7 @@ use clap::Parser;
use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core_plugins::ConfiguredMarketplace;
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginsConfigInput;
use codex_core_plugins::PluginsManager;
@@ -15,10 +16,14 @@ use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
use std::path::Path;
use std::path::PathBuf;
use crate::marketplace_cmd::MarketplaceCli;
const OPENAI_BUNDLED_ALPHA_MARKETPLACE_NAME: &str = "openai-bundled-alpha";
const OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME: &str = "openai-primary-runtime";
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin")]
pub struct PluginCli {
@@ -168,9 +173,13 @@ pub async fn run_plugin_list(
println!("No marketplace plugins found.");
}
} else {
for marketplace in marketplaces {
println!("Marketplace `{}`", marketplace.name);
println!("Path: {}", marketplace.path.as_path().display());
for (index, marketplace) in marketplaces.into_iter().enumerate() {
let mut rows = Vec::new();
let mut plugin_width = "PLUGIN".len();
let mut status_width = "STATUS".len();
let mut installed_version_width = "VERSION".len();
let mut path_width = "PATH".len();
for plugin in &marketplace.plugins {
let state = if plugin.installed && plugin.enabled {
"installed, enabled"
@@ -179,7 +188,51 @@ pub async fn run_plugin_list(
} else {
"not installed"
};
println!(" {} ({state})", plugin.id);
let installed_version = plugin.installed_version.clone().unwrap_or_default();
let path = match &plugin.source {
codex_core_plugins::marketplace::MarketplacePluginSource::Local { path } => {
path.as_path().display().to_string()
}
codex_core_plugins::marketplace::MarketplacePluginSource::Git {
url,
path,
ref_name,
sha,
} => {
let mut parts = vec![url.clone()];
if let Some(path) = path {
parts.push(format!("path `{path}`"));
}
if let Some(ref_name) = ref_name {
parts.push(format!("ref `{ref_name}`"));
}
if let Some(sha) = sha {
parts.push(format!("sha `{sha}`"));
}
parts.join(", ")
}
};
plugin_width = plugin_width.max(plugin.id.len());
status_width = status_width.max(state.len());
installed_version_width = installed_version_width.max(installed_version.len());
path_width = path_width.max(path.len());
rows.push((plugin.id.clone(), state, installed_version, path));
}
if index > 0 {
println!();
}
println!("Marketplace `{}`", marketplace.name);
println!("{}", marketplace.path.as_path().display());
println!();
println!(
"{:<plugin_width$} {:<status_width$} {:<installed_version_width$} {:<path_width$}",
"PLUGIN", "STATUS", "VERSION", "PATH"
);
for (plugin, status, installed_version, path) in rows {
println!(
"{plugin:<plugin_width$} {status:<status_width$} {installed_version:<installed_version_width$} {path:<path_width$}"
);
}
}
}
@@ -306,10 +359,10 @@ fn find_marketplace_for_plugin(
}
}
struct ConfiguredMarketplaceSnapshotIssue {
marketplace_name: String,
path: PathBuf,
message: String,
pub(crate) struct ConfiguredMarketplaceSnapshotIssue {
pub(crate) marketplace_name: String,
pub(crate) path: PathBuf,
pub(crate) message: String,
}
fn ensure_configured_marketplace_snapshots_loaded(
@@ -343,17 +396,16 @@ fn ensure_configured_marketplace_snapshots_loaded(
bail!("failed to load configured marketplace snapshot(s):\n{issue_lines}");
}
fn configured_marketplace_snapshot_issues(
pub(crate) fn configured_marketplace_snapshot_issues(
codex_home: &std::path::Path,
plugins_input: &PluginsConfigInput,
load_errors: &[MarketplaceListError],
marketplace_name: Option<&str>,
) -> Vec<ConfiguredMarketplaceSnapshotIssue> {
let Some(user_layer) = plugins_input.config_layer_stack.get_active_user_layer() else {
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
return Vec::new();
};
let Some(configured_marketplaces) = user_layer
.config
let Some(configured_marketplaces) = user_config
.get("marketplaces")
.and_then(toml::Value::as_table)
else {
@@ -367,9 +419,33 @@ fn configured_marketplace_snapshot_issues(
if marketplace_name.is_some_and(|name| configured_name != name) {
continue;
}
if !marketplace.is_table()
|| validate_plugin_segment(configured_name, "marketplace name").is_err()
if !marketplace.is_table() {
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid config>"),
message: "configured marketplace entry must be a table".to_string(),
});
continue;
}
if let Err(err) = validate_plugin_segment(configured_name, "marketplace name") {
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid config>"),
message: err.to_string(),
});
continue;
}
if marketplace.get("source_type").and_then(toml::Value::as_str) == Some("local")
&& marketplace
.get("source")
.and_then(toml::Value::as_str)
.is_none_or(str::is_empty)
{
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid source>"),
message: "configured local marketplace source is missing or empty".to_string(),
});
continue;
}
let Some(root) = resolve_configured_marketplace_root(
@@ -381,11 +457,16 @@ fn configured_marketplace_snapshot_issues(
};
match find_marketplace_manifest_path(&root) {
Some(path) => manifest_paths.push((configured_name.clone(), path)),
None => issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: root,
message: "marketplace root does not contain a supported manifest".to_string(),
}),
None => {
if is_implicit_system_marketplace_root(configured_name, codex_home, &root) {
continue;
}
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: root,
message: "marketplace root does not contain a supported manifest".to_string(),
});
}
}
}
@@ -403,3 +484,41 @@ fn configured_marketplace_snapshot_issues(
}
issues
}
fn is_implicit_system_marketplace_root(
marketplace_name: &str,
_codex_home: &Path,
root: &Path,
) -> bool {
if matches!(
marketplace_name,
OPENAI_BUNDLED_MARKETPLACE_NAME | OPENAI_BUNDLED_ALPHA_MARKETPLACE_NAME
) && path_ends_with(root, &[".tmp", "bundled-marketplaces", marketplace_name])
{
return true;
}
marketplace_name == OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME
&& path_ends_with(
root,
&[
"codex-runtimes",
"codex-primary-runtime",
"plugins",
marketplace_name,
],
)
}
fn path_ends_with(path: &Path, suffix: &[&str]) -> bool {
let path_components = path
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>();
path_components.as_slice().ends_with(
&suffix
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>(),
)
}
+457 -34
View File
@@ -7,9 +7,21 @@ use predicates::str::contains;
use std::path::Path;
use tempfile::TempDir;
const MARKETPLACE_HEADER: &str = "MARKETPLACE";
const MARKETPLACE_LIST_HEADER: &str = "MARKETPLACE ROOT";
fn marketplace_list_row(marketplace_name: &str, root: &Path) -> String {
format!(
"{marketplace_name:<width$} {}",
root.display(),
width = MARKETPLACE_HEADER.len()
)
}
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
cmd.env("HOME", codex_home);
Ok(cmd)
}
@@ -40,11 +52,30 @@ plugins = true
Ok(())
}
fn write_marketplace_source(source: &Path) -> Result<()> {
std::fs::create_dir_all(source.join(".agents/plugins"))?;
std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;
fn write_marketplace_source_with_manifest(source: &Path, marketplace_manifest: &str) -> Result<()> {
std::fs::create_dir_all(source.join(".agents").join("plugins"))?;
std::fs::create_dir_all(source.join("plugins").join("sample").join(".codex-plugin"))?;
std::fs::write(
source.join(".agents/plugins/marketplace.json"),
source
.join(".agents")
.join("plugins")
.join("marketplace.json"),
marketplace_manifest,
)?;
std::fs::write(
source
.join("plugins")
.join("sample")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"sample","version":"1.2.3","description":"Sample plugin"}"#,
)?;
Ok(())
}
fn write_marketplace_source(source: &Path) -> Result<()> {
write_marketplace_source_with_manifest(
source,
r#"{
"name": "debug",
"plugins": [
@@ -57,12 +88,28 @@ fn write_marketplace_source(source: &Path) -> Result<()> {
}
]
}"#,
)?;
std::fs::write(
source.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample","description":"Sample plugin"}"#,
)?;
Ok(())
)
}
fn write_marketplace_source_with_explicit_empty_products(source: &Path) -> Result<()> {
write_marketplace_source_with_manifest(
source,
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
},
"policy": {
"products": []
}
}
]
}"#,
)
}
fn setup_local_marketplace() -> Result<(TempDir, TempDir)> {
@@ -87,6 +134,20 @@ fn setup_unconfigured_local_marketplace() -> Result<(TempDir, TempDir)> {
Ok((codex_home, source))
}
fn setup_local_marketplace_with_explicit_empty_products() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
write_marketplace_source_with_explicit_empty_products(source.path())?;
let source_path = source.path().to_string_lossy().into_owned();
record_user_marketplace(
codex_home.path(),
"debug",
&configured_local_marketplace(&source_path),
)?;
Ok((codex_home, source))
}
fn setup_configured_marketplace_without_manifest() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
@@ -104,9 +165,13 @@ fn setup_configured_marketplace_with_malformed_manifest() -> Result<(TempDir, Te
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
std::fs::create_dir_all(source.path().join(".agents/plugins"))?;
std::fs::create_dir_all(source.path().join(".agents").join("plugins"))?;
std::fs::write(
source.path().join(".agents/plugins/marketplace.json"),
source
.path()
.join(".agents")
.join("plugins")
.join("marketplace.json"),
"{not valid json",
)?;
let source_path = source.path().to_string_lossy().into_owned();
@@ -118,30 +183,328 @@ fn setup_configured_marketplace_with_malformed_manifest() -> Result<(TempDir, Te
Ok((codex_home, source))
}
fn setup_local_marketplace_with_implicit_system_roots() -> Result<(TempDir, TempDir, TempDir)> {
let (codex_home, source) = setup_local_marketplace()?;
let bundled_root = codex_home
.path()
.join(".tmp")
.join("bundled-marketplaces")
.join("openai-bundled");
std::fs::create_dir_all(&bundled_root)?;
let bundled_source = bundled_root.display().to_string();
record_user_marketplace(
codex_home.path(),
"openai-bundled",
&configured_local_marketplace(&bundled_source),
)?;
let cache_home = TempDir::new()?;
let runtime_root = cache_home
.path()
.join("codex-runtimes")
.join("codex-primary-runtime")
.join("plugins")
.join("openai-primary-runtime");
std::fs::create_dir_all(&runtime_root)?;
let runtime_source = runtime_root.display().to_string();
record_user_marketplace(
codex_home.path(),
"openai-primary-runtime",
&configured_local_marketplace(&runtime_source),
)?;
Ok((codex_home, source, cache_home))
}
fn setup_custom_marketplace_under_implicit_system_root() -> Result<(TempDir, std::path::PathBuf)> {
let codex_home = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
let custom_root = codex_home
.path()
.join(".tmp")
.join("bundled-marketplaces")
.join("custom-marketplace");
std::fs::create_dir_all(&custom_root)?;
let custom_source = custom_root.display().to_string();
record_user_marketplace(
codex_home.path(),
"custom-marketplace",
&configured_local_marketplace(&custom_source),
)?;
Ok((codex_home, custom_root))
}
fn remove_installed_plugin_config(codex_home: &Path, plugin_key: &str) -> Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let plugin_header = format!("[plugins.\"{plugin_key}\"]");
let config = std::fs::read_to_string(&config_path)?;
let mut rewritten = Vec::new();
let mut skipping = false;
for line in config.lines() {
if line == plugin_header {
skipping = true;
continue;
}
if skipping && line.starts_with('[') {
skipping = false;
}
if !skipping {
rewritten.push(line);
}
}
std::fs::write(config_path, format!("{}\n", rewritten.join("\n")))?;
Ok(())
}
fn setup_configured_local_marketplace_with_missing_source() -> Result<TempDir> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces.debug]
source_type = "local"
"#,
)?;
Ok(codex_home)
}
fn setup_configured_local_marketplace_with_invalid_name() -> Result<TempDir> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces."bad/name"]
source_type = "local"
source = "/tmp/debug"
"#,
)?;
Ok(codex_home)
}
fn assert_configured_marketplace_snapshot_failure(
assert: assert_cmd::assert::Assert,
source: &Path,
detail: &str,
) {
assert
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains(source.display().to_string()))
.stderr(contains(detail));
}
fn assert_marketplace_failure(
assert: assert_cmd::assert::Assert,
marketplace_name: &str,
source: &Path,
detail: &str,
) {
assert
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains(format!("`{marketplace_name}`")))
.stderr(contains(source.display().to_string()))
.stderr(contains(detail));
}
#[tokio::test]
async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> {
let (codex_home, source) = setup_local_marketplace()?;
let expected_row = marketplace_list_row("debug", source.path());
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains("debug"))
.stdout(contains(source.path().display().to_string()));
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row))
.stdout(contains("\t").not());
Ok(())
}
#[tokio::test]
async fn plugin_list_shows_plugins_grouped_by_marketplace() -> Result<()> {
let (codex_home, _source) = setup_local_marketplace()?;
async fn marketplace_list_includes_home_marketplace_when_present() -> Result<()> {
let codex_home = TempDir::new()?;
let home = TempDir::new()?;
write_marketplace_source(home.path())?;
write_plugins_enabled_config(codex_home.path())?;
let expected_row = marketplace_list_row("debug", home.path());
codex_command(codex_home.path())?
.env("HOME", home.path())
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row))
.stdout(contains("\t").not());
Ok(())
}
#[tokio::test]
async fn marketplace_list_includes_root_when_plugins_are_filtered_out() -> Result<()> {
let (codex_home, source) = setup_local_marketplace_with_explicit_empty_products()?;
let expected_row = marketplace_list_row("debug", source.path());
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_without_manifest()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"debug",
source.path(),
"marketplace root does not contain a supported manifest",
);
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_name_is_invalid() -> Result<()> {
let codex_home = setup_configured_local_marketplace_with_invalid_name()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"bad/name",
Path::new("<invalid config>"),
"marketplace name",
);
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_local_marketplace_source_is_missing() -> Result<()>
{
let codex_home = setup_configured_local_marketplace_with_missing_source()?;
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains("`debug`"))
.stderr(contains("<invalid source>"))
.stderr(contains(
"configured local marketplace source is missing or empty",
));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_home_marketplace_is_malformed() -> Result<()> {
let codex_home = TempDir::new()?;
let home = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
std::fs::create_dir_all(home.path().join(".agents/plugins"))?;
let home_marketplace_path = home
.path()
.join(".agents")
.join("plugins")
.join("marketplace.json");
std::fs::write(&home_marketplace_path, "{not valid json")?;
codex_command(codex_home.path())?
.env("HOME", home.path())
.args(["plugin", "marketplace", "list"])
.assert()
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains(home_marketplace_path.display().to_string()))
.stderr(contains("key must be a string"));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"debug",
source.path(),
"key must be a string",
);
Ok(())
}
#[tokio::test]
async fn plugin_list_prints_plugins_in_a_table() -> Result<()> {
let (codex_home, source) = setup_local_marketplace()?;
let marketplace_manifest = source
.path()
.join(".agents")
.join("plugins")
.join("marketplace.json");
let plugin_path = source.path().join("plugins").join("sample");
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert()
.success()
.stdout(contains("Marketplace `debug`"))
.stdout(contains("sample@debug (not installed)"));
.stdout(contains("PLUGIN"))
.stdout(contains("STATUS"))
.stdout(contains("VERSION"))
.stdout(contains("PATH"))
.stdout(contains(marketplace_manifest.display().to_string()))
.stdout(contains("sample@debug"))
.stdout(contains("not installed"))
.stdout(contains(plugin_path.display().to_string()));
Ok(())
}
#[tokio::test]
async fn plugin_list_shows_installed_version_when_plugin_is_installed() -> Result<()> {
let (codex_home, _source) = setup_local_marketplace()?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.success();
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert()
.success()
.stdout(contains("sample@debug"))
.stdout(contains("1.2.3"))
.stdout(contains("installed, enabled"));
Ok(())
}
@@ -151,10 +514,10 @@ async fn plugin_list_excludes_unconfigured_repo_local_marketplaces() -> Result<(
let (codex_home, source) = setup_unconfigured_local_marketplace()?;
codex_command_in(codex_home.path(), source.path())?
.args(["plugin", "list"])
.args(["plugin", "list", "--marketplace", "debug"])
.assert()
.success()
.stdout(contains("No marketplace plugins found."))
.stdout(contains("No plugins found in marketplace `debug`."))
.stdout(predicates::str::is_match("sample@debug").unwrap().not());
Ok(())
@@ -164,6 +527,47 @@ async fn plugin_list_excludes_unconfigured_repo_local_marketplaces() -> Result<(
async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_without_manifest()?;
assert_configured_marketplace_snapshot_failure(
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert(),
source.path(),
"marketplace root does not contain a supported manifest",
);
Ok(())
}
#[tokio::test]
async fn plugin_list_ignores_implicit_system_marketplace_roots_without_manifests() -> Result<()> {
let (codex_home, source, cache_home) = setup_local_marketplace_with_implicit_system_roots()?;
codex_command(codex_home.path())?
.env("XDG_CACHE_HOME", cache_home.path())
.args(["plugin", "list"])
.assert()
.success()
.stdout(contains("Marketplace `debug`"))
.stdout(contains(
source
.path()
.join(".agents")
.join("plugins")
.join("marketplace.json")
.display()
.to_string(),
))
.stderr(
predicates::str::contains("failed to load configured marketplace snapshot(s):").not(),
);
Ok(())
}
#[tokio::test]
async fn plugin_list_fails_for_custom_marketplace_under_system_root() -> Result<()> {
let (codex_home, custom_root) = setup_custom_marketplace_under_implicit_system_root()?;
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert()
@@ -171,8 +575,8 @@ async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() ->
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains(source.path().display().to_string()))
.stderr(contains("`custom-marketplace`"))
.stderr(contains(custom_root.display().to_string()))
.stderr(contains(
"marketplace root does not contain a supported manifest",
));
@@ -180,6 +584,28 @@ async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() ->
Ok(())
}
#[tokio::test]
async fn plugin_list_hides_version_for_cached_but_unconfigured_plugin() -> Result<()> {
let (codex_home, _source) = setup_local_marketplace()?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.success();
remove_installed_plugin_config(codex_home.path(), "sample@debug")?;
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert()
.success()
.stdout(contains("sample@debug"))
.stdout(contains("not installed"))
.stdout(predicates::str::contains("1.2.3").not());
Ok(())
}
#[tokio::test]
async fn plugin_add_and_remove_updates_installed_plugin_config() -> Result<()> {
let (codex_home, _source) = setup_local_marketplace()?;
@@ -224,18 +650,15 @@ async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()>
#[tokio::test]
async fn plugin_add_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> {
let (codex_home, _source) = setup_configured_marketplace_with_malformed_manifest()?;
let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains("invalid marketplace file"))
.stderr(contains("key must be a string"));
assert_configured_marketplace_snapshot_failure(
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert(),
source.path(),
"key must be a string",
);
Ok(())
}
@@ -258,7 +681,7 @@ async fn plugin_add_reinstalls_from_configured_marketplace_snapshot() -> Result<
assert!(
codex_home
.path()
.join("plugins/cache/debug/sample/local/.codex-plugin/plugin.json")
.join("plugins/cache/debug/sample/1.2.3/.codex-plugin/plugin.json")
.is_file()
);
@@ -311,7 +734,7 @@ async fn plugin_add_rejects_cached_plugins_without_authorizing_marketplace_snaps
assert!(
codex_home
.path()
.join("plugins/cache/debug/sample/local/.codex-plugin/plugin.json")
.join("plugins/cache/debug/sample/1.2.3/.codex-plugin/plugin.json")
.is_file()
);
@@ -993,6 +993,22 @@ mod tests {
);
}
#[test]
fn parse_v1_input_transcript_turn_marked_event() {
let payload = json!({
"type": "conversation.input_transcript.turn_marked",
"transcript": "hello realtime"
})
.to_string();
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::V1),
Some(RealtimeEvent::InputTranscriptDone(RealtimeTranscriptDone {
text: "hello realtime".to_string(),
}))
);
}
#[test]
fn parse_output_transcript_delta_event() {
let payload = json!({
@@ -1581,6 +1597,10 @@ mod tests {
.expect("text");
let third_json: Value = serde_json::from_str(&third).expect("json");
assert_eq!(third_json["type"], "conversation.item.create");
assert_eq!(
third_json["item"]["content"][0]["type"],
Value::String("input_text".to_string())
);
assert_eq!(third_json["item"]["content"][0]["text"], "hello agent");
let fourth = ws
@@ -21,7 +21,7 @@ pub(super) fn conversation_item_create_message(text: String) -> RealtimeOutbound
r#type: ConversationItemType::Message,
role: ConversationRole::User,
content: vec![ConversationItemContent {
r#type: ConversationContentType::Text,
r#type: ConversationContentType::InputText,
text,
}],
}),
@@ -199,7 +199,6 @@ pub(super) struct ConversationItemContent {
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum ConversationContentType {
Text,
InputText,
}
@@ -43,7 +43,8 @@ pub(super) fn parse_realtime_event_v1(payload: &str) -> Option<RealtimeEvent> {
| "conversation.item.input_audio_transcription.delta" => {
parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::InputTranscriptDelta)
}
"conversation.item.input_audio_transcription.completed" => {
"conversation.input_transcript.turn_marked"
| "conversation.item.input_audio_transcription.completed" => {
parse_transcript_done_event(&parsed, "transcript")
.map(RealtimeEvent::InputTranscriptDone)
}
@@ -76,6 +76,7 @@ async fn models_client_hits_models_endpoint() {
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+2 -1
View File
@@ -28,6 +28,7 @@ codex-utils-path = { workspace = true }
dunce = { workspace = true }
futures = { workspace = true, features = ["alloc", "std"] }
gethostname = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
multimap = { workspace = true }
prost = "0.14.3"
schemars = { workspace = true }
@@ -38,7 +39,7 @@ serde_path_to_error = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
toml = { workspace = true }
toml = { workspace = true, features = ["preserve_order"] }
toml_edit = { workspace = true }
tonic = { workspace = true }
tonic-prost = { workspace = true }
+1 -1
View File
@@ -359,7 +359,7 @@ pub struct ConfigToml {
pub personality: Option<Personality>,
/// Optional explicit service tier request id for new turns (for example
/// `priority` or `flex`; legacy `fast` also works).
/// `default`, `priority`, or `flex`; legacy `fast` also works).
pub service_tier: Option<String>,
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
+8 -1
View File
@@ -47,6 +47,8 @@ pub struct HookEventsToml {
pub user_prompt_submit: Vec<MatcherGroup>,
#[serde(rename = "SubagentStart", default)]
pub subagent_start: Vec<MatcherGroup>,
#[serde(rename = "SubagentStop", default)]
pub subagent_stop: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
}
@@ -62,6 +64,7 @@ impl HookEventsToml {
session_start,
user_prompt_submit,
subagent_start,
subagent_stop,
stop,
} = self;
pre_tool_use.is_empty()
@@ -72,6 +75,7 @@ impl HookEventsToml {
&& session_start.is_empty()
&& user_prompt_submit.is_empty()
&& subagent_start.is_empty()
&& subagent_stop.is_empty()
&& stop.is_empty()
}
@@ -85,6 +89,7 @@ impl HookEventsToml {
session_start,
user_prompt_submit,
subagent_start,
subagent_stop,
stop,
} = self;
[
@@ -96,6 +101,7 @@ impl HookEventsToml {
session_start,
user_prompt_submit,
subagent_start,
subagent_stop,
stop,
]
.into_iter()
@@ -104,7 +110,7 @@ impl HookEventsToml {
.sum()
}
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 9] {
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 10] {
[
(HookEventName::PreToolUse, self.pre_tool_use),
(HookEventName::PermissionRequest, self.permission_request),
@@ -114,6 +120,7 @@ impl HookEventsToml {
(HookEventName::SessionStart, self.session_start),
(HookEventName::UserPromptSubmit, self.user_prompt_submit),
(HookEventName::SubagentStart, self.subagent_start),
(HookEventName::SubagentStop, self.subagent_stop),
(HookEventName::Stop, self.stop),
]
}
+206
View File
@@ -1,12 +1,18 @@
use std::collections::BTreeMap;
use crate::merge::merge_toml_values;
use codex_network_proxy::InjectedHeaderConfig;
use codex_network_proxy::MitmHookActionsConfig;
use codex_network_proxy::MitmHookBodyConfig;
use codex_network_proxy::MitmHookConfig;
use codex_network_proxy::MitmHookMatchConfig;
use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
use codex_network_proxy::normalize_host;
use codex_protocol::permissions::FileSystemAccessMode;
use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -350,6 +356,38 @@ pub struct NetworkToml {
pub domains: Option<NetworkDomainPermissionsToml>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
pub allow_local_binding: Option<bool>,
pub mitm: Option<NetworkMitmToml>,
}
#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkMitmToml {
#[schemars(with = "Option<BTreeMap<String, NetworkMitmHookToml>>")]
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
#[schemars(with = "Option<BTreeMap<String, NetworkMitmActionToml>>")]
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkMitmTomlUnchecked {
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkMitmHookToml {
pub host: String,
pub methods: Vec<String>,
pub path_prefixes: Vec<String>,
#[serde(default)]
pub query: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub headers: BTreeMap<String, Vec<String>>,
#[schemars(with = "Option<MitmHookBodyConfigSchema>")]
pub body: Option<MitmHookBodyConfig>,
pub action: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -359,6 +397,114 @@ enum NetworkModeSchema {
Full,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
pub struct NetworkMitmActionToml {
pub strip_request_headers: Vec<String>,
pub inject_request_headers: Vec<NetworkMitmInjectedHeaderToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
pub struct NetworkMitmInjectedHeaderToml {
pub name: String,
pub secret_env_var: Option<String>,
pub secret_file: Option<String>,
pub prefix: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(transparent)]
struct MitmHookBodyConfigSchema(pub serde_json::Value);
impl<'de> Deserialize<'de> for NetworkMitmToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?;
let mitm = Self {
hooks: unchecked.hooks,
actions: unchecked.actions,
};
mitm.validate_action_definitions()
.map_err(serde::de::Error::custom)?;
Ok(mitm)
}
}
impl NetworkMitmToml {
pub fn validate_action_definitions(&self) -> Result<(), String> {
if let Some(actions) = self.actions.as_ref() {
for (action_name, action) in actions {
if action.is_empty() {
return Err(format!(
"network.mitm.actions.{action_name} must define at least one operation"
));
}
}
}
let Some(hooks) = self.hooks.as_ref() else {
return Ok(());
};
for (hook_name, hook) in hooks {
if hook.action.is_empty() {
return Err(format!(
"network.mitm.hooks.{hook_name}.action must not be empty"
));
}
}
Ok(())
}
pub fn validate_action_references(
&self,
actions_by_name: &IndexMap<String, NetworkMitmActionToml>,
) -> Result<(), String> {
self.validate_action_definitions()?;
let Some(hooks) = self.hooks.as_ref() else {
return Ok(());
};
for (hook_name, hook) in hooks {
for action_name in &hook.action {
if !actions_by_name.contains_key(action_name) {
return Err(format!(
"network.mitm.hooks.{hook_name}.action references undefined action `{action_name}`"
));
}
}
}
Ok(())
}
pub fn to_runtime_hooks(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> Vec<MitmHookConfig> {
self.hooks
.as_ref()
.map(|hooks| {
hooks
.values()
.map(|hook| hook.to_runtime(actions_by_name))
.collect()
})
.unwrap_or_default()
}
}
impl NetworkMitmActionToml {
pub fn is_empty(&self) -> bool {
self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty()
}
}
impl NetworkToml {
pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) {
if let Some(enabled) = self.enabled {
@@ -411,6 +557,11 @@ impl NetworkToml {
if let Some(allow_local_binding) = self.allow_local_binding {
config.network.allow_local_binding = allow_local_binding;
}
if let Some(mitm) = self.mitm.as_ref() {
config.network.mitm_hooks = mitm.to_runtime_hooks(mitm.actions.as_ref());
}
config.network.mitm =
config.network.mode == NetworkMode::Limited || !config.network.mitm_hooks.is_empty();
}
pub fn to_network_proxy_config(&self) -> NetworkProxyConfig {
@@ -420,6 +571,61 @@ impl NetworkToml {
}
}
impl NetworkMitmHookToml {
fn to_runtime(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> MitmHookConfig {
MitmHookConfig {
host: self.host.clone(),
matcher: MitmHookMatchConfig {
methods: self.methods.clone(),
path_prefixes: self.path_prefixes.clone(),
query: self.query.clone(),
headers: self.headers.clone(),
body: self.body.clone(),
},
actions: self.selected_actions(actions_by_name),
}
}
fn selected_actions(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> MitmHookActionsConfig {
let Some(actions_by_name) = actions_by_name else {
return MitmHookActionsConfig::default();
};
let mut selected = MitmHookActionsConfig::default();
for action_name in &self.action {
if let Some(action) = actions_by_name.get(action_name) {
selected
.strip_request_headers
.extend(action.strip_request_headers.clone());
selected.inject_request_headers.extend(
action
.inject_request_headers
.iter()
.map(NetworkMitmInjectedHeaderToml::to_runtime),
);
}
}
selected
}
}
impl NetworkMitmInjectedHeaderToml {
fn to_runtime(&self) -> InjectedHeaderConfig {
InjectedHeaderConfig {
name: self.name.clone(),
secret_env_var: self.secret_env_var.clone(),
secret_file: self.secret_file.clone(),
prefix: self.prefix.clone(),
}
}
}
pub fn overlay_network_domain_permissions(
config: &mut NetworkProxyConfig,
domains: &NetworkDomainPermissionsToml,
+1 -1
View File
@@ -24,7 +24,7 @@ use codex_protocol::protocol::AskForApproval;
pub struct ConfigProfile {
pub model: Option<String>,
/// Optional explicit service tier request id for new turns (for example
/// `priority` or `flex`; legacy `fast` also works).
/// `default`, `priority`, or `flex`; legacy `fast` also works).
pub service_tier: Option<String>,
/// The key in the `model_providers` map identifying the
/// [`ModelProviderInfo`] to use.
+33 -5
View File
@@ -22,6 +22,7 @@ use crate::manifest::load_plugin_manifest;
use crate::marketplace::MarketplaceError;
use crate::marketplace::MarketplaceInterface;
use crate::marketplace::MarketplaceListError;
use crate::marketplace::MarketplaceListOutcome;
use crate::marketplace::MarketplacePluginAuthPolicy;
use crate::marketplace::MarketplacePluginPolicy;
use crate::marketplace::MarketplacePluginSource;
@@ -263,6 +264,7 @@ pub struct ConfiguredMarketplacePlugin {
pub id: String,
pub name: String,
pub local_version: Option<String>,
pub installed_version: Option<String>,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
@@ -1207,7 +1209,7 @@ impl PluginsManager {
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let marketplace_outcome =
list_marketplaces(&self.marketplace_roots(config, additional_roots))?;
self.discover_marketplaces_for_config(config, additional_roots)?;
let mut seen_plugin_keys = HashSet::new();
let marketplaces = marketplace_outcome
.marketplaces
@@ -1225,15 +1227,21 @@ impl PluginsManager {
if !self.restriction_product_matches(plugin.policy.products.as_deref()) {
return None;
}
let plugin_id =
PluginId::new(plugin.name.clone(), marketplace_name.clone()).ok();
let installed = installed_plugins.contains(&plugin_key);
let installed_version = installed.then_some(()).and_then(|_| {
plugin_id
.as_ref()
.and_then(|plugin_id| self.store.active_plugin_version(plugin_id))
});
let enabled = enabled_plugins.contains(&plugin_key);
let mut interface = plugin.interface;
let mut local_version = plugin.local_version;
if installed
&& matches!(&plugin.source, MarketplacePluginSource::Git { .. })
&& let Ok(plugin_id) =
PluginId::new(plugin.name.clone(), marketplace_name.clone())
&& let Some(plugin_root) = self.store.active_plugin_root(&plugin_id)
&& let Some(plugin_id) = plugin_id.as_ref()
&& let Some(plugin_root) = self.store.active_plugin_root(plugin_id)
&& let Some(manifest) = load_plugin_manifest(plugin_root.as_path())
{
local_version = manifest.version.clone();
@@ -1251,6 +1259,7 @@ impl PluginsManager {
// plugin entries from duplicate marketplace files intentionally
// resolve to the first discovered source.
id: plugin_key,
installed_version,
installed,
enabled,
name: plugin.name,
@@ -1278,6 +1287,18 @@ impl PluginsManager {
})
}
pub fn discover_marketplaces_for_config(
&self,
config: &PluginsConfigInput,
additional_roots: &[AbsolutePathBuf],
) -> Result<MarketplaceListOutcome, MarketplaceError> {
if !config.plugins_enabled {
return Ok(MarketplaceListOutcome::default());
}
list_marketplaces(&self.marketplace_roots(config, additional_roots))
}
pub async fn read_plugin_for_config(
&self,
config: &PluginsConfigInput,
@@ -1298,6 +1319,12 @@ impl PluginsManager {
let marketplace_name = plugin.plugin_id.marketplace_name.clone();
let plugin_key = plugin.plugin_id.as_key();
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let installed = installed_plugins.contains(&plugin_key);
let installed_version = if installed {
self.store.active_plugin_version(&plugin.plugin_id)
} else {
None
};
let plugin = self
.read_plugin_detail_for_marketplace_plugin(
config,
@@ -1309,6 +1336,7 @@ impl PluginsManager {
.manifest
.as_ref()
.and_then(|manifest| manifest.version.clone()),
installed_version,
source: plugin.source,
policy: plugin.policy,
interface: plugin.interface,
@@ -1317,7 +1345,7 @@ impl PluginsManager {
.as_ref()
.map(|manifest| manifest.keywords.clone())
.unwrap_or_default(),
installed: installed_plugins.contains(&plugin_key),
installed,
enabled: enabled_plugins.contains(&plugin_key),
},
)
@@ -1599,6 +1599,7 @@ enabled = false
id: "enabled-plugin@debug".to_string(),
name: "enabled-plugin".to_string(),
local_version: None,
installed_version: Some("local".to_string()),
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
.unwrap(),
@@ -1617,6 +1618,7 @@ enabled = false
id: "disabled-plugin@debug".to_string(),
name: "disabled-plugin".to_string(),
local_version: None,
installed_version: Some("local".to_string()),
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),)
.unwrap(),
@@ -1738,6 +1740,7 @@ plugins = true
id: "default-plugin@debug".to_string(),
name: "default-plugin".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(),
},
@@ -2170,6 +2173,7 @@ enabled = true
id: "toolkit@debug".to_string(),
name: "toolkit".to_string(),
local_version: None,
installed_version: Some("local".to_string()),
source: MarketplacePluginSource::Git {
url: missing_remote_repo_url,
path: Some("plugins/toolkit".to_string()),
@@ -2287,6 +2291,7 @@ plugins = true
id: "linear@openai-curated".to_string(),
name: "linear".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(),
},
@@ -2582,6 +2587,7 @@ enabled = false
id: "dup-plugin@debug".to_string(),
name: "dup-plugin".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
},
@@ -2613,6 +2619,7 @@ enabled = false
id: "b-only-plugin@debug".to_string(),
name: "b-only-plugin".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
},
@@ -2698,6 +2705,7 @@ enabled = true
id: "sample-plugin@debug".to_string(),
name: "sample-plugin".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(),
},
+2 -1
View File
@@ -698,7 +698,8 @@ pub fn plugin_interface_with_marketplace_category(
interface
}
fn marketplace_root_dir(
#[doc(hidden)]
pub fn marketplace_root_dir(
marketplace_path: &AbsolutePathBuf,
) -> Result<AbsolutePathBuf, MarketplaceError> {
for relative_path in MARKETPLACE_MANIFEST_RELATIVE_PATHS {
+125 -3
View File
@@ -686,7 +686,7 @@
"$ref": "#/definitions/SandboxMode"
},
"service_tier": {
"description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).",
"description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).",
"type": "string"
},
"tools": {
@@ -1129,6 +1129,13 @@
},
"type": "array"
},
"SubagentStop": {
"default": [],
"items": {
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"default": [],
"items": {
@@ -1577,6 +1584,118 @@
"NetworkDomainPermissionsToml": {
"type": "object"
},
"NetworkMitmActionToml": {
"properties": {
"inject_request_headers": {
"default": [],
"items": {
"$ref": "#/definitions/NetworkMitmInjectedHeaderToml"
},
"type": "array"
},
"strip_request_headers": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"NetworkMitmHookToml": {
"additionalProperties": false,
"properties": {
"action": {
"items": {
"type": "string"
},
"type": "array"
},
"body": true,
"headers": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"default": {},
"type": "object"
},
"host": {
"type": "string"
},
"methods": {
"items": {
"type": "string"
},
"type": "array"
},
"path_prefixes": {
"items": {
"type": "string"
},
"type": "array"
},
"query": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"default": {},
"type": "object"
}
},
"required": [
"action",
"host",
"methods",
"path_prefixes"
],
"type": "object"
},
"NetworkMitmInjectedHeaderToml": {
"properties": {
"name": {
"default": "",
"type": "string"
},
"prefix": {
"default": null,
"type": "string"
},
"secret_env_var": {
"default": null,
"type": "string"
},
"secret_file": {
"default": null,
"type": "string"
}
},
"type": "object"
},
"NetworkMitmToml": {
"additionalProperties": false,
"properties": {
"actions": {
"additionalProperties": {
"$ref": "#/definitions/NetworkMitmActionToml"
},
"type": "object"
},
"hooks": {
"additionalProperties": {
"$ref": "#/definitions/NetworkMitmHookToml"
},
"type": "object"
}
},
"type": "object"
},
"NetworkModeSchema": {
"enum": [
"limited",
@@ -1680,6 +1799,9 @@
"enabled": {
"type": "boolean"
},
"mitm": {
"$ref": "#/definitions/NetworkMitmToml"
},
"mode": {
"$ref": "#/definitions/NetworkModeSchema"
},
@@ -4776,7 +4898,7 @@
"description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`."
},
"service_tier": {
"description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).",
"description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).",
"type": "string"
},
"shell_environment_policy": {
@@ -4876,4 +4998,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}
+1 -2
View File
@@ -748,8 +748,7 @@ impl ModelClient {
prompt.output_schema_strict,
);
let prompt_cache_key = Some(self.state.thread_id.to_string());
let service_tier =
service_tier.filter(|service_tier| model_info.supports_service_tier(service_tier));
let service_tier = model_info.service_tier_for_request(service_tier);
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions: instructions.clone(),
+264 -3
View File
@@ -26,6 +26,9 @@ use codex_config::permissions_toml::FilesystemPermissionToml;
use codex_config::permissions_toml::FilesystemPermissionsToml;
use codex_config::permissions_toml::NetworkDomainPermissionToml;
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
use codex_config::permissions_toml::NetworkMitmActionToml;
use codex_config::permissions_toml::NetworkMitmHookToml;
use codex_config::permissions_toml::NetworkMitmToml;
use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
@@ -70,6 +73,7 @@ use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use codex_model_provider_info::WireApi;
use codex_models_manager::bundled_models_response;
use codex_network_proxy::NetworkMode;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
@@ -95,6 +99,7 @@ use core_test_support::PathBufExt;
use core_test_support::PathExt;
use core_test_support::TempDirExt;
use core_test_support::test_absolute_path;
use indexmap::IndexMap;
use pretty_assertions::assert_eq;
use rmcp::model::ElicitationCapability;
use rmcp::model::FormElicitationCapability;
@@ -759,6 +764,15 @@ mode = "full"
[permissions.dev.network.domains]
"openai.com" = "allow"
[permissions.dev.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST", "PUT"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
[permissions.dev.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
@@ -816,6 +830,27 @@ mode = "full"
}),
unix_sockets: None,
allow_local_binding: None,
mitm: Some(NetworkMitmToml {
hooks: Some(IndexMap::from([(
"github_write".to_string(),
NetworkMitmHookToml {
host: "api.github.com".to_string(),
methods: vec!["POST".to_string(), "PUT".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
query: BTreeMap::new(),
headers: BTreeMap::new(),
body: None,
action: vec!["strip_auth".to_string()],
},
)])),
actions: Some(IndexMap::from([(
"strip_auth".to_string(),
NetworkMitmActionToml {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: Vec::new(),
},
)])),
}),
}),
},
)]),
@@ -823,6 +858,140 @@ mode = "full"
);
}
#[test]
fn config_toml_rejects_empty_mitm_action_reference_list() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = []
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
"#;
let err =
toml::from_str::<ConfigToml>(toml).expect_err("empty MITM action refs should fail closed");
assert!(
err.to_string()
.contains("network.mitm.hooks.github_write.action must not be empty"),
"{err}"
);
}
#[test]
fn config_toml_rejects_empty_mitm_action_definition() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
[permissions.workspace.network.mitm.actions.strip_auth]
"#;
let err = toml::from_str::<ConfigToml>(toml)
.expect_err("empty MITM action definitions should fail closed");
assert!(
err.to_string()
.contains("network.mitm.actions.strip_auth must define at least one operation"),
"{err}"
);
}
#[test]
fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() {
let network = NetworkToml {
mode: Some(NetworkMode::Full),
mitm: Some(NetworkMitmToml {
hooks: Some(IndexMap::from([(
"github_write".to_string(),
NetworkMitmHookToml {
host: "api.github.com".to_string(),
methods: vec!["POST".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
action: vec!["strip_auth".to_string()],
..NetworkMitmHookToml::default()
},
)])),
actions: Some(IndexMap::from([(
"strip_auth".to_string(),
NetworkMitmActionToml {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: Vec::new(),
},
)])),
}),
..NetworkToml::default()
};
let config = network.to_network_proxy_config();
assert_eq!(config.network.mode, NetworkMode::Full);
assert!(config.network.mitm);
assert_eq!(config.network.mitm_hooks.len(), 1);
assert_eq!(config.network.mitm_hooks[0].host, "api.github.com");
assert_eq!(
config.network.mitm_hooks[0].matcher.methods,
vec!["POST".to_string()]
);
assert_eq!(
config.network.mitm_hooks[0].actions.strip_request_headers,
vec!["authorization".to_string()]
);
}
#[test]
fn permissions_profile_network_to_proxy_config_preserves_mitm_hook_declaration_order() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.network.mitm.actions.noop]
strip_request_headers = ["authorization"]
[permissions.workspace.network.mitm.hooks.z_first]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = ["noop"]
[permissions.workspace.network.mitm.hooks.a_second]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/"]
action = ["noop"]
"#;
let cfg: ConfigToml = toml::from_str(toml).expect("permissions profile should deserialize");
let permissions = cfg.permissions.expect("permissions should deserialize");
let network = permissions
.entries
.get("workspace")
.expect("workspace profile should exist")
.network
.as_ref()
.expect("network profile should exist");
let config = network.to_network_proxy_config();
assert_eq!(config.network.mitm_hooks.len(), 2);
assert_eq!(
config.network.mitm_hooks[0].matcher.path_prefixes,
vec!["/repos/openai/".to_string()]
);
assert_eq!(
config.network.mitm_hooks[1].matcher.path_prefixes,
vec!["/repos/".to_string()]
);
}
#[tokio::test]
async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature()
-> std::io::Result<()> {
@@ -8277,7 +8446,7 @@ alpha = "one\ntwo"
}
#[tokio::test]
async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std::io::Result<()> {
async fn explicit_null_service_tier_override_maps_to_default_service_tier() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
@@ -8291,8 +8460,33 @@ async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std:
)
.await?;
assert_eq!(config.service_tier, None);
assert_eq!(config.notices.fast_default_opt_out, Some(true));
assert_eq!(
config.service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
assert_eq!(config.notices.fast_default_opt_out, None);
Ok(())
}
#[tokio::test]
async fn default_service_tier_override_uses_default_request_value() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
service_tier: Some(Some("default".to_string())),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
Ok(())
}
@@ -9712,6 +9906,73 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
Ok(())
}
#[tokio::test]
async fn danger_full_access_with_never_is_rejected_when_requirements_force_read_only()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"approval_policy = "never"
sandbox_mode = "danger-full-access"
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
..Default::default()
}))
}))
.build()
.await
.expect_err("requirements-constrained yolo should require sandbox approval");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"`approval_policy = \"never\"` cannot be used because requirements do not allow `sandbox_mode = \"danger-full-access\"`; Codex would fall back to read-only permissions with approvals disabled. Choose an `approval_policy` based on what you need, such as `on-request`, or choose an allowed sandbox mode."
);
Ok(())
}
#[tokio::test]
async fn named_full_access_profile_with_never_is_rejected_when_requirements_force_read_only()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"approval_policy = "never"
default_permissions = "dev"
[permissions.dev.filesystem]
":root" = "write"
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
..Default::default()
}))
}))
.build()
.await
.expect_err("requirements-constrained full-access profile should require sandbox approval");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"`approval_policy = \"never\"` cannot be used because requirements do not allow `sandbox_mode = \"danger-full-access\"`; Codex would fall back to read-only permissions with approvals disabled. Choose an `approval_policy` based on what you need, such as `on-request`, or choose an allowed sandbox mode."
);
Ok(())
}
#[tokio::test]
async fn permission_profile_override_falls_back_when_disallowed_by_requirements()
-> std::io::Result<()> {
+2 -13
View File
@@ -40,8 +40,6 @@ pub enum ConfigEdit {
SetNoticeHideFullAccessWarning(bool),
/// Toggle the Windows world-writable directories warning acknowledgement flag.
SetNoticeHideWorldWritableWarning(bool),
/// Toggle the opt-out marker for Codex-managed fast defaults.
SetNoticeFastDefaultOptOut(bool),
/// Toggle the rate limit model nudge acknowledgement flag.
SetNoticeHideRateLimitModelNudge(bool),
/// Toggle the model migration prompt acknowledgement flag.
@@ -552,6 +550,8 @@ impl ConfigDocument {
ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value(
&["service_tier"],
service_tier.as_ref().map(|service_tier| {
// Keep the legacy config spelling stable. Runtime values use
// `priority`, but config.toml continues to store it as `fast`.
let config_value = match ServiceTier::from_request_value(service_tier) {
Some(ServiceTier::Fast) => "fast",
Some(ServiceTier::Flex) => "flex",
@@ -574,11 +574,6 @@ impl ConfigDocument {
&[NOTICE_TABLE_KEY, "hide_world_writable_warning"],
value(*acknowledged),
)),
ConfigEdit::SetNoticeFastDefaultOptOut(opted_out) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "fast_default_opt_out"],
value(*opted_out),
)),
ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"],
@@ -1182,12 +1177,6 @@ impl ConfigEditsBuilder {
self
}
pub fn set_fast_default_opt_out(mut self, opted_out: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeFastDefaultOptOut(opted_out));
self
}
pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged));
+15
View File
@@ -4,6 +4,7 @@ use codex_config::types::McpServerOAuthConfig;
use codex_config::types::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::SessionPickerViewMode;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
@@ -34,6 +35,20 @@ model_reasoning_effort = "high"
assert_eq!(contents, expected);
}
#[test]
fn set_service_tier_saves_default_as_default() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.set_service_tier(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()))
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
assert_eq!(contents, "service_tier = \"default\"\n");
}
#[test]
fn set_service_tier_saves_priority_as_fast() {
let tmp = tempdir().expect("tmpdir");
+15 -7
View File
@@ -81,6 +81,7 @@ use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::ShellEnvironmentPolicy;
@@ -519,6 +520,7 @@ pub struct Config {
pub model: Option<String>,
/// Effective service tier request id preference for new turns.
/// `default` means the user explicitly selected standard routing.
pub service_tier: Option<String>,
/// Model used specifically for review sessions.
@@ -3136,15 +3138,10 @@ impl Config {
let forced_login_method = cfg.forced_login_method;
let model = model.or(config_profile.model).or(cfg.model);
let mut notices = cfg.notice.unwrap_or_default();
let notices = cfg.notice.unwrap_or_default();
let service_tier = match service_tier_override {
Some(Some(service_tier)) => Some(service_tier),
Some(None) => {
// Preserve explicit standard/clear intent after the nested override
// collapses into `Config.service_tier = None`.
notices.fast_default_opt_out = Some(true);
None
}
Some(None) => Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()),
None => config_profile.service_tier.or(cfg.service_tier),
};
let service_tier = service_tier.and_then(|service_tier| {
@@ -3304,6 +3301,17 @@ impl Config {
&mut constrained_permission_profile,
&mut startup_warnings,
)?;
if permission_profile_was_constrained
&& sandbox_mode_requirement_for_permission_profile(&original_permission_profile)
== SandboxModeRequirement::DangerFullAccess
&& constrained_permission_profile.get() == &PermissionProfile::read_only()
&& constrained_approval_policy.value() == AskForApproval::Never
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"`approval_policy = \"never\"` cannot be used because requirements do not allow `sandbox_mode = \"danger-full-access\"`; Codex would fall back to read-only permissions with approvals disabled. Choose an `approval_policy` based on what you need, such as `on-request`, or choose an allowed sandbox mode.",
));
}
if permission_profile_was_constrained {
// The selected profile no longer describes the effective
// permissions after requirements forced a fallback.
+1
View File
@@ -186,6 +186,7 @@ pub(crate) fn apply_network_proxy_feature_config(
}
}),
allow_local_binding: feature_config.allow_local_binding,
mitm: None,
}
.apply_to_network_proxy_config(config);
}
+24 -10
View File
@@ -433,10 +433,10 @@ pub(crate) async fn execute_exec_request(
expiration,
capture_policy,
sandbox,
windows_sandbox_policy_cwd: _,
windows_sandbox_policy_cwd,
windows_sandbox_level,
windows_sandbox_private_desktop,
permission_profile: _,
permission_profile,
file_system_sandbox_policy: _,
network_sandbox_policy,
windows_sandbox_filesystem_overrides,
@@ -465,6 +465,8 @@ pub(crate) async fn execute_exec_request(
after_spawn,
sandbox,
&sandbox_policy,
&permission_profile,
&windows_sandbox_policy_cwd,
windows_sandbox_filesystem_overrides.as_ref(),
)
.await;
@@ -472,6 +474,7 @@ pub(crate) async fn execute_exec_request(
finalize_exec_result(raw_output_result, sandbox, duration)
}
#[allow(clippy::too_many_arguments)]
async fn get_raw_output_result(
params: ExecParams,
network_sandbox_policy: NetworkSandboxPolicy,
@@ -479,14 +482,22 @@ async fn get_raw_output_result(
after_spawn: Option<Box<dyn FnOnce() + Send>>,
#[cfg_attr(not(windows), allow(unused_variables))] sandbox: SandboxType,
#[cfg_attr(not(windows), allow(unused_variables))] sandbox_policy: &SandboxPolicy,
#[cfg_attr(not(windows), allow(unused_variables))] permission_profile: &PermissionProfile,
#[cfg_attr(not(windows), allow(unused_variables))] windows_sandbox_policy_cwd: &AbsolutePathBuf,
#[cfg_attr(not(windows), allow(unused_variables))] windows_sandbox_filesystem_overrides: Option<
&WindowsSandboxFilesystemOverrides,
>,
) -> Result<RawExecToolCallOutput> {
#[cfg(target_os = "windows")]
if sandbox == SandboxType::WindowsRestrictedToken {
return exec_windows_sandbox(params, sandbox_policy, windows_sandbox_filesystem_overrides)
.await;
return exec_windows_sandbox(
params,
sandbox_policy,
permission_profile,
windows_sandbox_policy_cwd,
windows_sandbox_filesystem_overrides,
)
.await;
}
exec(params, network_sandbox_policy, stdout_stream, after_spawn).await
@@ -562,10 +573,12 @@ fn record_windows_sandbox_spawn_failure(
async fn exec_windows_sandbox(
params: ExecParams,
sandbox_policy: &SandboxPolicy,
permission_profile: &PermissionProfile,
windows_sandbox_policy_cwd: &AbsolutePathBuf,
windows_sandbox_filesystem_overrides: Option<&WindowsSandboxFilesystemOverrides>,
) -> Result<RawExecToolCallOutput> {
use crate::config::find_codex_home;
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
use codex_windows_sandbox::run_windows_sandbox_capture_for_permission_profile_elevated;
use codex_windows_sandbox::run_windows_sandbox_capture_with_filesystem_overrides;
let ExecParams {
@@ -596,7 +609,8 @@ async fn exec_windows_sandbox(
"failed to serialize Windows sandbox policy: {err}"
)))
})?;
let sandbox_cwd = cwd.clone();
let sandbox_cwd = windows_sandbox_policy_cwd.clone();
let permission_profile = permission_profile.clone();
let codex_home = find_codex_home().map_err(|err| {
CodexErr::Io(io::Error::other(format!(
"windows sandbox: failed to resolve codex_home: {err}"
@@ -620,10 +634,10 @@ async fn exec_windows_sandbox(
.and_then(|overrides| overrides.write_roots_override.clone());
let spawn_res = tokio::task::spawn_blocking(move || {
if use_elevated {
run_windows_sandbox_capture_elevated(
codex_windows_sandbox::ElevatedSandboxCaptureRequest {
policy_json_or_preset: policy_str.as_str(),
sandbox_policy_cwd: &sandbox_cwd,
run_windows_sandbox_capture_for_permission_profile_elevated(
codex_windows_sandbox::ElevatedSandboxProfileCaptureRequest {
permission_profile: &permission_profile,
permission_profile_cwd: &sandbox_cwd,
codex_home: codex_home.as_ref(),
command,
cwd: &cwd,
+75 -25
View File
@@ -14,8 +14,8 @@ use codex_hooks::PreToolUseOutcome;
use codex_hooks::PreToolUseRequest;
use codex_hooks::SessionStartOutcome;
use codex_hooks::StartHookTarget;
use codex_hooks::StopHookTarget;
use codex_hooks::StopOutcome;
use codex_hooks::StopRequest;
use codex_hooks::UserPromptSubmitOutcome;
use codex_hooks::UserPromptSubmitRequest;
use codex_otel::HOOK_RUN_DURATION_METRIC;
@@ -33,6 +33,7 @@ use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::HookStartedEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_thread_store::ReadThreadParams;
use serde_json::Value;
use crate::context::ContextualUserFragment;
@@ -288,6 +289,78 @@ pub(crate) async fn run_post_tool_use_hooks(
outcome
}
pub(crate) async fn run_turn_stop_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
stop_hook_active: bool,
last_assistant_message: Option<String>,
) -> StopOutcome {
// Resolve the stop hook kind from the session source before building the
// request. Root turns run Stop; thread-spawned child turns run SubagentStop.
let (target, transcript_path) = match &turn_context.session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
agent_role,
parent_thread_id,
..
}) => {
let agent_type = agent_role
.clone()
.unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string());
let agent_transcript_path = sess.hook_transcript_path().await;
let parent_transcript_path = match sess
.services
.thread_store
.read_thread(ReadThreadParams {
thread_id: *parent_thread_id,
include_archived: true,
include_history: false,
})
.await
{
Ok(thread) => thread.rollout_path,
Err(error) => {
tracing::warn!(
parent_thread_id = %parent_thread_id,
error = %error,
"failed to resolve parent transcript path for subagent hook"
);
None
}
};
(
StopHookTarget::SubagentStop {
agent_id: sess.thread_id().to_string(),
agent_type,
agent_transcript_path,
},
parent_transcript_path,
)
}
// Internal/synthetic subagents do not expose user-configured lifecycle
// hooks, so there is no Stop or SubagentStop request to dispatch.
SessionSource::SubAgent(_) => return StopOutcome::default(),
_ => (StopHookTarget::Stop, sess.hook_transcript_path().await),
};
let request = codex_hooks::StopRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
stop_hook_active,
last_assistant_message,
target,
};
let hooks = sess.hooks();
emit_hook_started_events(sess, turn_context, hooks.preview_stop(&request)).await;
let mut outcome = hooks.run_stop(request).await;
emit_hook_completed_events(sess, turn_context, std::mem::take(&mut outcome.hook_events)).await;
outcome
}
pub(crate) async fn run_pre_compact_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -352,30 +425,6 @@ pub(crate) async fn run_post_compact_hooks(
}
}
pub(crate) async fn run_stop_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
stop_hook_active: bool,
last_assistant_message: Option<String>,
) -> StopOutcome {
let request = StopRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
stop_hook_active,
last_assistant_message,
};
let hooks = sess.hooks();
emit_hook_started_events(sess, turn_context, hooks.preview_stop(&request)).await;
let mut outcome = hooks.run_stop(request).await;
emit_hook_completed_events(sess, turn_context, std::mem::take(&mut outcome.hook_events)).await;
outcome
}
pub(crate) async fn run_legacy_after_agent_hook(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -638,6 +687,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
HookEventName::Stop => "Stop",
};
let hook_source = match run.source {
+31 -18
View File
@@ -138,9 +138,14 @@ pub(crate) async fn handle_mcp_tool_call(
let metadata =
lookup_mcp_tool_metadata(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
let mcp_app_resource_uri = metadata
.as_ref()
.and_then(|metadata| metadata.mcp_app_resource_uri.clone());
let item_metadata = McpToolCallItemMetadata {
mcp_app_resource_uri: metadata
.as_ref()
.and_then(|metadata| metadata.mcp_app_resource_uri.clone()),
plugin_id: metadata
.as_ref()
.and_then(|metadata| metadata.plugin_id.clone()),
};
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
connectors::app_tool_policy(
&turn_context.config,
@@ -171,7 +176,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
item_metadata.clone(),
"MCP tool call blocked by app configuration".to_string(),
/*already_started*/ false,
)
@@ -200,7 +205,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation.clone(),
mcp_app_resource_uri.clone(),
item_metadata.clone(),
)
.await;
@@ -225,7 +230,7 @@ pub(crate) async fn handle_mcp_tool_call(
&call_id,
invocation,
metadata.as_ref(),
mcp_app_resource_uri,
item_metadata,
)
.await;
}
@@ -236,7 +241,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
item_metadata.clone(),
message,
/*already_started*/ true,
)
@@ -249,7 +254,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
item_metadata.clone(),
message,
/*already_started*/ true,
)
@@ -280,7 +285,7 @@ pub(crate) async fn handle_mcp_tool_call(
&call_id,
invocation,
metadata.as_ref(),
mcp_app_resource_uri,
item_metadata,
)
.await
}
@@ -290,13 +295,19 @@ pub(crate) struct HandledMcpToolCall {
pub(crate) tool_input: JsonValue,
}
#[derive(Clone)]
struct McpToolCallItemMetadata {
mcp_app_resource_uri: Option<String>,
plugin_id: Option<String>,
}
async fn handle_approved_mcp_tool_call(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
mcp_app_resource_uri: Option<String>,
item_metadata: McpToolCallItemMetadata,
) -> HandledMcpToolCall {
let server = invocation.server.clone();
maybe_mark_thread_memory_mode_polluted(sess, turn_context, &server).await;
@@ -365,7 +376,7 @@ async fn handle_approved_mcp_tool_call(
turn_context,
call_id,
invocation,
mcp_app_resource_uri,
item_metadata,
duration,
truncate_mcp_tool_result_for_event(&result),
)
@@ -840,7 +851,7 @@ async fn notify_mcp_tool_call_started(
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
mcp_app_resource_uri: Option<String>,
item_metadata: McpToolCallItemMetadata,
) {
let McpInvocation {
server,
@@ -852,7 +863,8 @@ async fn notify_mcp_tool_call_started(
server,
tool,
arguments: arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri,
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
plugin_id: item_metadata.plugin_id,
status: McpToolCallStatus::InProgress,
result: None,
error: None,
@@ -866,7 +878,7 @@ async fn notify_mcp_tool_call_completed(
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
mcp_app_resource_uri: Option<String>,
item_metadata: McpToolCallItemMetadata,
duration: Duration,
result: Result<CallToolResult, String>,
) {
@@ -891,7 +903,8 @@ async fn notify_mcp_tool_call_completed(
server,
tool,
arguments: arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri,
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
plugin_id: item_metadata.plugin_id,
status,
result,
error,
@@ -2092,7 +2105,7 @@ async fn notify_mcp_tool_call_skip(
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
mcp_app_resource_uri: Option<String>,
item_metadata: McpToolCallItemMetadata,
message: String,
already_started: bool,
) -> Result<CallToolResult, String> {
@@ -2102,7 +2115,7 @@ async fn notify_mcp_tool_call_skip(
turn_context,
call_id,
invocation.clone(),
mcp_app_resource_uri.clone(),
item_metadata.clone(),
)
.await;
}
@@ -2112,7 +2125,7 @@ async fn notify_mcp_tool_call_skip(
turn_context,
call_id,
invocation,
mcp_app_resource_uri,
item_metadata,
Duration::ZERO,
truncate_mcp_tool_result_for_event(&Err(message.clone())),
)
+34
View File
@@ -1112,6 +1112,40 @@ async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() {
);
}
#[tokio::test]
async fn mcp_tool_call_item_includes_plugin_id() {
let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;
notify_mcp_tool_call_started(
&session,
&turn_context,
"call-plugin",
McpInvocation {
server: "sample".to_string(),
tool: "echo".to_string(),
arguments: None,
},
McpToolCallItemMetadata {
mcp_app_resource_uri: None,
plugin_id: Some("sample@test".to_string()),
},
)
.await;
let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_event.recv())
.await
.expect("tool call item timed out")
.expect("tool call item event");
let EventMsg::ItemStarted(item_started) = event.msg else {
panic!("expected ItemStarted event");
};
let TurnItem::McpToolCall(item) = item_started.item else {
panic!("expected MCP tool call item");
};
assert_eq!(item.plugin_id.as_deref(), Some("sample@test"));
}
#[tokio::test]
async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() {
let (_, turn_context) = make_session_and_context().await;
+56 -2
View File
@@ -16,12 +16,16 @@ use codex_config::ConfigLayerStackOrdering;
use codex_config::LoaderOverrides;
use codex_config::loader::load_config_layers_state;
use codex_config::merge_toml_values;
use codex_config::permissions_toml::NetworkMitmActionToml;
use codex_config::permissions_toml::NetworkMitmHookToml;
use codex_config::permissions_toml::NetworkMitmToml;
use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_config::permissions_toml::overlay_network_domain_permissions;
use codex_exec_server::LOCAL_FS;
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraintError;
use codex_network_proxy::NetworkProxyConstraints;
@@ -30,6 +34,7 @@ use codex_network_proxy::build_config_state;
use codex_network_proxy::normalize_host;
use codex_network_proxy::validate_policy_against_constraints;
use codex_utils_absolute_path::AbsolutePathBuf;
use indexmap::IndexMap;
use serde::Deserialize;
use std::sync::Arc;
use tokio::sync::RwLock;
@@ -208,6 +213,7 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<Netw
Ok(profile.profile.network)
}
#[cfg(test)]
fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) -> Result<()> {
if let Some(network) = selected_network_from_tables(parsed)? {
network.apply_to_network_proxy_config(config);
@@ -215,11 +221,57 @@ fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesTo
Ok(())
}
#[derive(Default)]
struct NetworkConfigAccumulator {
config: NetworkProxyConfig,
mitm_hooks: IndexMap<String, NetworkMitmHookToml>,
mitm_actions: IndexMap<String, NetworkMitmActionToml>,
}
impl NetworkConfigAccumulator {
fn apply_network_tables(&mut self, parsed: NetworkTablesToml) -> Result<()> {
if let Some(network) = selected_network_from_tables(parsed)? {
self.apply_network(network);
}
Ok(())
}
fn apply_network(&mut self, mut network: NetworkToml) {
let mitm = network.mitm.take();
network.apply_to_network_proxy_config(&mut self.config);
if let Some(mitm) = mitm {
if let Some(actions) = mitm.actions {
self.mitm_actions.extend(actions);
}
if let Some(hooks) = mitm.hooks {
self.mitm_hooks.extend(hooks);
}
}
}
fn finish(mut self) -> Result<NetworkProxyConfig> {
if !self.mitm_hooks.is_empty() {
let actions = self.mitm_actions;
let mitm = NetworkMitmToml {
hooks: Some(self.mitm_hooks),
actions: Some(actions.clone()),
};
mitm.validate_action_references(&actions)
.map_err(anyhow::Error::msg)?;
self.config.network.mitm_hooks = mitm.to_runtime_hooks(Some(&actions));
}
self.config.network.mitm = self.config.network.mode == NetworkMode::Limited
|| !self.config.network.mitm_hooks.is_empty();
Ok(self.config)
}
}
fn config_from_layers(
layers: &ConfigLayerStack,
exec_policy: &codex_execpolicy::Policy,
) -> Result<NetworkProxyConfig> {
let mut config = NetworkProxyConfig::default();
let mut merged = toml::Value::Table(toml::map::Map::new());
for layer in layers.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
@@ -228,7 +280,9 @@ fn config_from_layers(
merge_toml_values(&mut merged, &layer.config);
}
let parsed = network_tables_from_toml(&merged)?;
apply_network_tables(&mut config, parsed)?;
let mut accumulator = NetworkConfigAccumulator::default();
accumulator.apply_network_tables(parsed)?;
let mut config = accumulator.finish()?;
apply_exec_policy_network_rules(&mut config, exec_policy);
Ok(config)
}
@@ -112,6 +112,76 @@ default_permissions = "dev"
assert_eq!(config.network.denied_domains(), None);
}
#[test]
fn higher_precedence_profile_network_overrides_named_mitm_actions() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
mode = "full"
[permissions.workspace.network.domains]
"lower.example.com" = "allow"
[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
mode = "full"
[permissions.workspace.network.domains]
"higher.example.com" = "allow"
[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["x-api-key"]
"#,
)
.expect("higher layer should parse");
let mut accumulator = NetworkConfigAccumulator::default();
accumulator
.apply_network_tables(
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
)
.expect("lower layer should apply");
accumulator
.apply_network_tables(
network_tables_from_toml(&higher_network).expect("higher layer should deserialize"),
)
.expect("higher layer should apply");
let config = accumulator.finish().expect("merged config should build");
assert_eq!(config.network.mode, codex_network_proxy::NetworkMode::Full);
assert!(config.network.mitm);
assert_eq!(
config.network.allowed_domains(),
Some(vec![
"lower.example.com".to_string(),
"higher.example.com".to_string()
])
);
assert_eq!(config.network.mitm_hooks.len(), 1);
assert_eq!(config.network.mitm_hooks[0].host, "api.github.com");
assert_eq!(config.network.mitm_hooks[0].matcher.methods, vec!["POST"]);
assert_eq!(
config.network.mitm_hooks[0].actions.strip_request_headers,
vec!["x-api-key"]
);
}
#[test]
fn execpolicy_network_rules_overlay_network_lists() {
let mut config = NetworkProxyConfig::default();
@@ -641,12 +641,14 @@ async fn prepare_realtime_start(
realtime_request_headers(
requested_realtime_session_id.as_deref(),
Some(realtime_api_key.as_str()),
version,
)?
}
ConversationStartTransport::Webrtc { .. } => {
realtime_request_headers(
requested_realtime_session_id.as_deref(),
/*api_key*/ None,
version,
)?
}
};
@@ -973,9 +975,14 @@ fn realtime_api_key(auth: Option<&CodexAuth>, provider: &ModelProviderInfo) -> C
fn realtime_request_headers(
realtime_session_id: Option<&str>,
api_key: Option<&str>,
version: RealtimeWsVersion,
) -> CodexResult<Option<HeaderMap>> {
let mut headers = HeaderMap::new();
if version == RealtimeWsVersion::V1 {
headers.insert("openai-alpha", HeaderValue::from_static("quicksilver=v1"));
}
if let Some(realtime_session_id) = realtime_session_id
&& let Ok(realtime_session_id) = HeaderValue::from_str(realtime_session_id)
{
@@ -1,9 +1,11 @@
use super::RealtimeHandoffState;
use super::RealtimeSessionKind;
use super::realtime_delegation_from_handoff;
use super::realtime_request_headers;
use super::realtime_text_from_handoff_request;
use super::wrap_realtime_delegation_input;
use async_channel::bounded;
use codex_config::config_toml::RealtimeWsVersion;
use codex_protocol::protocol::RealtimeHandoffRequested;
use codex_protocol::protocol::RealtimeTranscriptEntry;
use pretty_assertions::assert_eq;
@@ -137,3 +139,28 @@ async fn clears_active_handoff_explicitly() {
*state.active_handoff.lock().await = None;
assert_eq!(state.active_handoff.lock().await.clone(), None);
}
#[test]
fn uses_quicksilver_alpha_header_for_realtime_v1() {
let headers =
realtime_request_headers(Some("session_1"), Some("sk-test"), RealtimeWsVersion::V1)
.expect("headers")
.expect("headers");
assert_eq!(
headers
.get("openai-alpha")
.and_then(|value| value.to_str().ok()),
Some("quicksilver=v1")
);
}
#[test]
fn omits_quicksilver_alpha_header_for_realtime_v2() {
let headers =
realtime_request_headers(Some("session_1"), Some("sk-test"), RealtimeWsVersion::V2)
.expect("headers")
.expect("headers");
assert!(headers.get("openai-alpha").is_none());
}
+9 -21
View File
@@ -72,7 +72,6 @@ use codex_otel::current_span_trace_id;
use codex_otel::current_span_w3c_trace_context;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyAmendment;
@@ -80,6 +79,7 @@ use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolResponse;
@@ -320,7 +320,6 @@ use codex_otel::TelemetryAuthMode;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
@@ -589,14 +588,10 @@ impl Codex {
developer_instructions: None,
},
};
let account_plan_type = auth_manager
.auth_cached()
.and_then(|auth| auth.account_plan_type());
let service_tier = get_service_tier(
config.service_tier.clone(),
config.notices.fast_default_opt_out.unwrap_or(false),
account_plan_type,
config.features.enabled(Feature::FastMode),
&model_info,
);
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
@@ -798,17 +793,16 @@ impl Codex {
fn get_service_tier(
configured_service_tier: Option<String>,
fast_default_opt_out: bool,
account_plan_type: Option<AccountPlanType>,
fast_mode_enabled: bool,
model_info: &ModelInfo,
) -> Option<String> {
if configured_service_tier.is_some() || fast_default_opt_out || !fast_mode_enabled {
return configured_service_tier;
if !fast_mode_enabled {
return None;
}
account_plan_type
.is_some_and(is_enterprise_default_service_tier_plan)
.then_some(ServiceTier::Fast.request_value().to_string())
configured_service_tier.filter(|service_tier| {
service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE
|| model_info.supports_service_tier(service_tier)
})
}
fn session_permission_profile_state_from_config(
@@ -817,12 +811,6 @@ fn session_permission_profile_state_from_config(
Ok(config.permissions.permission_profile_state().clone())
}
fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool {
plan_type == AccountPlanType::Enterprise
|| plan_type.is_business_like()
|| plan_type.is_team_like()
}
#[cfg(test)]
pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination {
futures::future::ready(()).boxed().shared()

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