mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
Merge branch 'main' into pakrym/refactor-modelprovider-api
This commit is contained in:
@@ -664,6 +664,7 @@ jobs:
|
||||
export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME=codex-remote-test-env
|
||||
source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh"
|
||||
echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV"
|
||||
echo "CODEX_TEST_REMOTE_EXEC_SERVER_URL=${CODEX_TEST_REMOTE_EXEC_SERVER_URL}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: tests
|
||||
id: test
|
||||
|
||||
Generated
+4
-2
File diff suppressed because one or more lines are too long
@@ -1,9 +1,10 @@
|
||||
[advisories]
|
||||
# Reviewed 2026-04-11. Keep this list in sync with ../deny.toml.
|
||||
# Reviewed 2026-04-15. Keep this list in sync with ../deny.toml.
|
||||
ignore = [
|
||||
"RUSTSEC-2024-0388", # derivative 2.2.0 via starlark; upstream crate is unmaintained
|
||||
"RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map; upstream crate is unmaintained
|
||||
"RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained
|
||||
"RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it
|
||||
"RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it
|
||||
"RUSTSEC-2026-0097", # rand 0.8.5 via age/codex-secrets and zbus/keyring; remove when transitive deps move to rand >=0.9.3
|
||||
]
|
||||
|
||||
Generated
+57
-25
@@ -1694,6 +1694,7 @@ dependencies = [
|
||||
"codex-stdio-to-uds",
|
||||
"codex-terminal-detection",
|
||||
"codex-tui",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-cli",
|
||||
"codex-utils-path",
|
||||
@@ -1728,7 +1729,7 @@ dependencies = [
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"pretty_assertions",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
@@ -1830,6 +1831,7 @@ name = "codex-code-mode"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-protocol",
|
||||
"deno_core_icudata",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
@@ -1905,6 +1907,7 @@ dependencies = [
|
||||
"codex-api",
|
||||
"codex-app-server-protocol",
|
||||
"codex-apply-patch",
|
||||
"codex-arg0",
|
||||
"codex-async-utils",
|
||||
"codex-code-mode",
|
||||
"codex-config",
|
||||
@@ -1956,6 +1959,7 @@ dependencies = [
|
||||
"ctor 0.6.3",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"ed25519-dalek",
|
||||
"env-flags",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
@@ -1973,7 +1977,7 @@ dependencies = [
|
||||
"opentelemetry_sdk",
|
||||
"predicates",
|
||||
"pretty_assertions",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"rmcp",
|
||||
@@ -2326,7 +2330,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"os_info",
|
||||
"pretty_assertions",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -2694,6 +2698,8 @@ dependencies = [
|
||||
name = "codex-sandboxing"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"codex-network-proxy",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
@@ -2702,6 +2708,7 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"which 8.0.0",
|
||||
@@ -2718,7 +2725,7 @@ dependencies = [
|
||||
"codex-keyring-store",
|
||||
"keyring",
|
||||
"pretty_assertions",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"regex",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
@@ -2917,7 +2924,7 @@ dependencies = [
|
||||
"pathdiff",
|
||||
"pretty_assertions",
|
||||
"pulldown-cmark",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"ratatui",
|
||||
"ratatui-macros",
|
||||
"regex-lite",
|
||||
@@ -3650,6 +3657,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
@@ -4126,7 +4134,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4230,6 +4238,30 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -5159,7 +5191,7 @@ dependencies = [
|
||||
"idna",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"ring",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
@@ -5181,7 +5213,7 @@ dependencies = [
|
||||
"moka",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
@@ -6728,7 +6760,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7327,7 +7359,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"opentelemetry",
|
||||
"percent-encoding",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -7876,7 +7908,7 @@ checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax 0.8.8",
|
||||
@@ -8047,7 +8079,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"ring",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
@@ -8179,7 +8211,7 @@ dependencies = [
|
||||
"rama-http-types",
|
||||
"rama-net",
|
||||
"rama-utils",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"serde",
|
||||
"serde_html_form",
|
||||
"serde_json",
|
||||
@@ -8249,7 +8281,7 @@ dependencies = [
|
||||
"rama-macros",
|
||||
"rama-net",
|
||||
"rama-utils",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"serde",
|
||||
"sha1",
|
||||
]
|
||||
@@ -8277,7 +8309,7 @@ dependencies = [
|
||||
"rama-error",
|
||||
"rama-macros",
|
||||
"rama-utils",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
@@ -8351,7 +8383,7 @@ dependencies = [
|
||||
"rama-http-types",
|
||||
"rama-net",
|
||||
"rama-utils",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -8431,9 +8463,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
@@ -8748,7 +8780,7 @@ dependencies = [
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"process-wrap",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"reqwest",
|
||||
"rmcp-macros",
|
||||
"schemars 1.2.1",
|
||||
@@ -8955,9 +8987,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
version = "0.103.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -9332,7 +9364,7 @@ version = "0.46.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26ab054c34b87f96c3e4701bea1888317cde30cc7e4a6136d2c48454ab96661c"
|
||||
dependencies = [
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"sentry-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -9380,7 +9412,7 @@ checksum = "eecbd63e9d15a26a40675ed180d376fcb434635d2e33de1c24003f61e3e2230d"
|
||||
dependencies = [
|
||||
"debugid",
|
||||
"hex",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
@@ -11165,7 +11197,7 @@ dependencies = [
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.3",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
@@ -11852,7 +11884,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -227,6 +227,7 @@ diffy = "0.4.2"
|
||||
dirs = "6"
|
||||
dotenvy = "0.15.7"
|
||||
dunce = "1.0.4"
|
||||
ed25519-dalek = { version = "2.2.0", features = ["pkcs8"] }
|
||||
encoding_rs = "0.8.35"
|
||||
env-flags = "0.1.1"
|
||||
env_logger = "0.11.9"
|
||||
|
||||
@@ -605,6 +605,16 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MigrationDetails"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"itemType": {
|
||||
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
|
||||
}
|
||||
@@ -620,6 +630,7 @@
|
||||
"AGENTS_MD",
|
||||
"CONFIG",
|
||||
"SKILLS",
|
||||
"PLUGINS",
|
||||
"MCP_SERVER_CONFIG"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -1359,6 +1370,20 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"MigrationDetails": {
|
||||
"properties": {
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginsMigration"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModeKind": {
|
||||
"description": "Initial collaboration mode to use when the TUI starts.",
|
||||
"enum": [
|
||||
@@ -1478,6 +1503,24 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginsMigration": {
|
||||
"properties": {
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginNames": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"pluginNames"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ReadOnlyAccess": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -7509,6 +7509,16 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/MigrationDetails"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"itemType": {
|
||||
"$ref": "#/definitions/v2/ExternalAgentConfigMigrationItemType"
|
||||
}
|
||||
@@ -7524,6 +7534,7 @@
|
||||
"AGENTS_MD",
|
||||
"CONFIG",
|
||||
"SKILLS",
|
||||
"PLUGINS",
|
||||
"MCP_SERVER_CONFIG"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -9551,6 +9562,20 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"MigrationDetails": {
|
||||
"properties": {
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/PluginsMigration"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModeKind": {
|
||||
"description": "Initial collaboration mode to use when the TUI starts.",
|
||||
"enum": [
|
||||
@@ -10473,6 +10498,24 @@
|
||||
"title": "PluginUninstallResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginsMigration": {
|
||||
"properties": {
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginNames": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"pluginNames"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProfileV2": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
|
||||
@@ -4126,6 +4126,16 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MigrationDetails"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"itemType": {
|
||||
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
|
||||
}
|
||||
@@ -4141,6 +4151,7 @@
|
||||
"AGENTS_MD",
|
||||
"CONFIG",
|
||||
"SKILLS",
|
||||
"PLUGINS",
|
||||
"MCP_SERVER_CONFIG"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -6323,6 +6334,20 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"MigrationDetails": {
|
||||
"properties": {
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginsMigration"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModeKind": {
|
||||
"description": "Initial collaboration mode to use when the TUI starts.",
|
||||
"enum": [
|
||||
@@ -7245,6 +7270,24 @@
|
||||
"title": "PluginUninstallResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginsMigration": {
|
||||
"properties": {
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginNames": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"pluginNames"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProfileV2": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MigrationDetails"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"itemType": {
|
||||
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
|
||||
}
|
||||
@@ -28,9 +38,42 @@
|
||||
"AGENTS_MD",
|
||||
"CONFIG",
|
||||
"SKILLS",
|
||||
"PLUGINS",
|
||||
"MCP_SERVER_CONFIG"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"MigrationDetails": {
|
||||
"properties": {
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginsMigration"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginsMigration": {
|
||||
"properties": {
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginNames": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"pluginNames"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MigrationDetails"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"itemType": {
|
||||
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
|
||||
}
|
||||
@@ -28,9 +38,42 @@
|
||||
"AGENTS_MD",
|
||||
"CONFIG",
|
||||
"SKILLS",
|
||||
"PLUGINS",
|
||||
"MCP_SERVER_CONFIG"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"MigrationDetails": {
|
||||
"properties": {
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginsMigration"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginsMigration": {
|
||||
"properties": {
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginNames": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"pluginNames"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
|
||||
+2
-1
@@ -2,9 +2,10 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType";
|
||||
import type { MigrationDetails } from "./MigrationDetails";
|
||||
|
||||
export type ExternalAgentConfigMigrationItem = { itemType: ExternalAgentConfigMigrationItemType, description: string,
|
||||
/**
|
||||
* Null or empty means home-scoped migration; non-empty means repo-scoped migration.
|
||||
*/
|
||||
cwd: string | null, };
|
||||
cwd: string | null, details: MigrationDetails | null, };
|
||||
|
||||
+1
-1
@@ -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 ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "MCP_SERVER_CONFIG";
|
||||
export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "PLUGINS" | "MCP_SERVER_CONFIG";
|
||||
|
||||
@@ -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 { PluginsMigration } from "./PluginsMigration";
|
||||
|
||||
export type MigrationDetails = { plugins: Array<PluginsMigration>, };
|
||||
@@ -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 PluginsMigration = { marketplaceName: string, pluginNames: Array<string>, };
|
||||
@@ -198,6 +198,7 @@ export type { McpToolCallStatus } from "./McpToolCallStatus";
|
||||
export type { MemoryCitation } from "./MemoryCitation";
|
||||
export type { MemoryCitationEntry } from "./MemoryCitationEntry";
|
||||
export type { MergeStrategy } from "./MergeStrategy";
|
||||
export type { MigrationDetails } from "./MigrationDetails";
|
||||
export type { Model } from "./Model";
|
||||
export type { ModelAvailabilityNux } from "./ModelAvailabilityNux";
|
||||
export type { ModelListParams } from "./ModelListParams";
|
||||
@@ -236,6 +237,7 @@ export type { PluginSource } from "./PluginSource";
|
||||
export type { PluginSummary } from "./PluginSummary";
|
||||
export type { PluginUninstallParams } from "./PluginUninstallParams";
|
||||
export type { PluginUninstallResponse } from "./PluginUninstallResponse";
|
||||
export type { PluginsMigration } from "./PluginsMigration";
|
||||
export type { ProfileV2 } from "./ProfileV2";
|
||||
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
|
||||
export type { RateLimitWindow } from "./RateLimitWindow";
|
||||
|
||||
@@ -289,6 +289,11 @@ client_request_definitions! {
|
||||
params: v2::ThreadMemoryModeSetParams,
|
||||
response: v2::ThreadMemoryModeSetResponse,
|
||||
},
|
||||
#[experimental("memory/reset")]
|
||||
MemoryReset => "memory/reset" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
response: v2::MemoryResetResponse,
|
||||
},
|
||||
ThreadUnarchive => "thread/unarchive" {
|
||||
params: v2::ThreadUnarchiveParams,
|
||||
response: v2::ThreadUnarchiveResponse,
|
||||
|
||||
@@ -944,11 +944,33 @@ pub enum ExternalAgentConfigMigrationItemType {
|
||||
#[serde(rename = "SKILLS")]
|
||||
#[ts(rename = "SKILLS")]
|
||||
Skills,
|
||||
#[serde(rename = "PLUGINS")]
|
||||
#[ts(rename = "PLUGINS")]
|
||||
Plugins,
|
||||
#[serde(rename = "MCP_SERVER_CONFIG")]
|
||||
#[ts(rename = "MCP_SERVER_CONFIG")]
|
||||
McpServerConfig,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginsMigration {
|
||||
#[serde(rename = "marketplaceName")]
|
||||
#[ts(rename = "marketplaceName")]
|
||||
pub marketplace_name: String,
|
||||
#[serde(rename = "pluginNames")]
|
||||
#[ts(rename = "pluginNames")]
|
||||
pub plugin_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct MigrationDetails {
|
||||
pub plugins: Vec<PluginsMigration>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -957,6 +979,7 @@ pub struct ExternalAgentConfigMigrationItem {
|
||||
pub description: String,
|
||||
/// Null or empty means home-scoped migration; non-empty means repo-scoped migration.
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub details: Option<MigrationDetails>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
@@ -3091,6 +3114,11 @@ pub struct ThreadMemoryModeSetParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadMemoryModeSetResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct MemoryResetResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -6592,6 +6620,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_agent_config_plugins_details_round_trip() {
|
||||
let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({
|
||||
"itemType": "PLUGINS",
|
||||
"description": "Install supported plugins from Claude settings",
|
||||
"cwd": absolute_path_string("repo"),
|
||||
"details": {
|
||||
"plugins": [
|
||||
{
|
||||
"marketplaceName": "team-marketplace",
|
||||
"pluginNames": ["asana"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
.expect("plugins migration item should deserialize");
|
||||
|
||||
assert_eq!(
|
||||
item,
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: "Install supported plugins from Claude settings".to_string(),
|
||||
cwd: Some(PathBuf::from(absolute_path_string("repo"))),
|
||||
details: Some(MigrationDetails {
|
||||
plugins: vec![PluginsMigration {
|
||||
marketplace_name: "team-marketplace".to_string(),
|
||||
plugin_names: vec!["asana".to_string()],
|
||||
}],
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_execution_request_approval_rejects_relative_additional_permission_paths() {
|
||||
let err = serde_json::from_value::<CommandExecutionRequestApprovalParams>(json!({
|
||||
|
||||
@@ -41,8 +41,11 @@ Security note:
|
||||
- Non-loopback websocket listeners currently allow unauthenticated connections by default during rollout. If you expose one remotely, configure websocket auth explicitly now.
|
||||
- Supported auth modes are app-server flags:
|
||||
- `--ws-auth capability-token --ws-token-file /absolute/path`
|
||||
- `--ws-auth capability-token --ws-token-sha256 HEX`
|
||||
- `--ws-auth signed-bearer-token --ws-shared-secret-file /absolute/path` for HMAC-signed JWT/JWS bearer tokens, with optional `--ws-issuer`, `--ws-audience`, `--ws-max-clock-skew-seconds`
|
||||
- Clients present the credential as `Authorization: Bearer <token>` during the websocket handshake. Auth is enforced before JSON-RPC `initialize`.
|
||||
- When starting `codex app-server` manually, prefer `--ws-token-file` over passing raw bearer tokens on the command line. Store a high-entropy token in a file readable only by your user, then have your client present that token in the websocket `Authorization` header.
|
||||
- `--ws-token-sha256` is intended for clients that keep the raw token in a separate local secret store and only need the server to know the SHA-256 verifier. The hash may appear in process listings, but it is not sufficient to authenticate; clients still need the original raw token. Only use this mode with randomly generated high-entropy tokens, not passwords or other guessable values.
|
||||
|
||||
Tracing/log output:
|
||||
|
||||
@@ -141,6 +144,7 @@ Example with notification opt-out:
|
||||
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
|
||||
- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
|
||||
- `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success.
|
||||
- `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`).
|
||||
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success and emits `thread/archived`.
|
||||
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`.
|
||||
@@ -196,8 +200,8 @@ Example with notification opt-out:
|
||||
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
|
||||
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
|
||||
- `config/read` — fetch the effective config on disk after resolving config layering.
|
||||
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home).
|
||||
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home).
|
||||
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin migration items may additionally include structured `details` grouping plugin ids under each detected marketplace name.
|
||||
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect.
|
||||
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
|
||||
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
|
||||
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly`.
|
||||
@@ -413,6 +417,13 @@ Experimental: use `thread/memoryMode/set` to change whether a thread remains eli
|
||||
{ "id": 26, "result": {} }
|
||||
```
|
||||
|
||||
Experimental: use `memory/reset` to clear local memory artifacts and sqlite-backed memory stage data for the current Codex home. This preserves existing thread memory modes; use `thread/memoryMode/set` separately when a thread's future memory eligibility should change.
|
||||
|
||||
```json
|
||||
{ "method": "memory/reset", "id": 27 }
|
||||
{ "id": 27, "result": {} }
|
||||
```
|
||||
|
||||
### Example: Archive a thread
|
||||
|
||||
Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory.
|
||||
|
||||
@@ -92,6 +92,7 @@ use codex_app_server_protocol::McpServerStatus;
|
||||
use codex_app_server_protocol::McpServerStatusDetail;
|
||||
use codex_app_server_protocol::McpServerToolCallParams;
|
||||
use codex_app_server_protocol::McpServerToolCallResponse;
|
||||
use codex_app_server_protocol::MemoryResetResponse;
|
||||
use codex_app_server_protocol::MockExperimentalMethodParams;
|
||||
use codex_app_server_protocol::MockExperimentalMethodResponse;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
@@ -908,6 +909,10 @@ impl CodexMessageProcessor {
|
||||
self.thread_memory_mode_set(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::MemoryReset { request_id, params } => {
|
||||
self.memory_reset(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::ThreadUnarchive { request_id, params } => {
|
||||
self.thread_unarchive(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
@@ -3046,6 +3051,86 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn memory_reset(&self, request_id: ConnectionRequestId, _params: Option<()>) {
|
||||
let state_db = match StateRuntime::init(
|
||||
self.config.sqlite_home.clone(),
|
||||
self.config.model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(state_db) => state_db,
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to open state db for memory reset: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = state_db.clear_memory_data().await {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to clear memory rows in state db: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let memory_root = self.config.codex_home.join("memories");
|
||||
let memory_extensions_root = self.config.codex_home.join("memories_extensions");
|
||||
let clear_memory_root_result: std::io::Result<()> = async {
|
||||
for directory in [memory_root.as_path(), memory_extensions_root.as_path()] {
|
||||
match tokio::fs::symlink_metadata(directory).await {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"refusing to clear symlinked memory root {}",
|
||||
directory.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
|
||||
tokio::fs::create_dir_all(directory).await?;
|
||||
let mut entries = tokio::fs::read_dir(directory).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
let file_type = entry.file_type().await?;
|
||||
if file_type.is_dir() {
|
||||
tokio::fs::remove_dir_all(path).await?;
|
||||
} else {
|
||||
tokio::fs::remove_file(path).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(err) = clear_memory_root_result {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!(
|
||||
"failed to clear memory directory {}: {err}",
|
||||
memory_root.display()
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
self.outgoing
|
||||
.send_response(request_id, MemoryResetResponse {})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn thread_metadata_update(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
|
||||
@@ -6,6 +6,8 @@ use codex_app_server_protocol::ExternalAgentConfigImportResponse;
|
||||
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
|
||||
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::MigrationDetails;
|
||||
use codex_app_server_protocol::PluginsMigration;
|
||||
use codex_core::external_agent_config::ExternalAgentConfigDetectOptions;
|
||||
use codex_core::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem;
|
||||
use codex_core::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
|
||||
@@ -51,12 +53,25 @@ impl ExternalAgentConfigApi {
|
||||
CoreMigrationItemType::AgentsMd => {
|
||||
ExternalAgentConfigMigrationItemType::AgentsMd
|
||||
}
|
||||
CoreMigrationItemType::Plugins => {
|
||||
ExternalAgentConfigMigrationItemType::Plugins
|
||||
}
|
||||
CoreMigrationItemType::McpServerConfig => {
|
||||
ExternalAgentConfigMigrationItemType::McpServerConfig
|
||||
}
|
||||
},
|
||||
description: migration_item.description,
|
||||
cwd: migration_item.cwd,
|
||||
details: migration_item.details.map(|details| MigrationDetails {
|
||||
plugins: details
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| PluginsMigration {
|
||||
marketplace_name: plugin.marketplace_name,
|
||||
plugin_names: plugin.plugin_names,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
@@ -82,15 +97,33 @@ impl ExternalAgentConfigApi {
|
||||
ExternalAgentConfigMigrationItemType::AgentsMd => {
|
||||
CoreMigrationItemType::AgentsMd
|
||||
}
|
||||
ExternalAgentConfigMigrationItemType::Plugins => {
|
||||
CoreMigrationItemType::Plugins
|
||||
}
|
||||
ExternalAgentConfigMigrationItemType::McpServerConfig => {
|
||||
CoreMigrationItemType::McpServerConfig
|
||||
}
|
||||
},
|
||||
description: migration_item.description,
|
||||
cwd: migration_item.cwd,
|
||||
details: migration_item.details.map(|details| {
|
||||
codex_core::external_agent_config::MigrationDetails {
|
||||
plugins: details
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| {
|
||||
codex_core::external_agent_config::PluginsMigration {
|
||||
marketplace_name: plugin.marketplace_name,
|
||||
plugin_names: plugin.plugin_names,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_io_error)?;
|
||||
|
||||
Ok(ExternalAgentConfigImportResponse {})
|
||||
|
||||
@@ -34,6 +34,10 @@ pub struct AppServerWebsocketAuthArgs {
|
||||
#[arg(long = "ws-token-file", value_name = "PATH")]
|
||||
pub ws_token_file: Option<PathBuf>,
|
||||
|
||||
/// Hex-encoded SHA-256 digest of the capability token.
|
||||
#[arg(long = "ws-token-sha256", value_name = "HEX")]
|
||||
pub ws_token_sha256: Option<String>,
|
||||
|
||||
/// Absolute path to the shared secret file for signed JWT bearer tokens.
|
||||
#[arg(long = "ws-shared-secret-file", value_name = "PATH")]
|
||||
pub ws_shared_secret_file: Option<PathBuf>,
|
||||
@@ -65,7 +69,7 @@ pub struct AppServerWebsocketAuthSettings {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppServerWebsocketAuthConfig {
|
||||
CapabilityToken {
|
||||
token_file: AbsolutePathBuf,
|
||||
source: AppServerWebsocketCapabilityTokenSource,
|
||||
},
|
||||
SignedBearerToken {
|
||||
shared_secret_file: AbsolutePathBuf,
|
||||
@@ -75,6 +79,12 @@ pub enum AppServerWebsocketAuthConfig {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppServerWebsocketCapabilityTokenSource {
|
||||
TokenFile { token_file: AbsolutePathBuf },
|
||||
TokenSha256 { token_sha256: [u8; 32] },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct WebsocketAuthPolicy {
|
||||
pub(crate) mode: Option<WebsocketAuthMode>,
|
||||
@@ -144,17 +154,34 @@ impl AppServerWebsocketAuthArgs {
|
||||
"`--ws-shared-secret-file`, `--ws-issuer`, `--ws-audience`, and `--ws-max-clock-skew-seconds` require `--ws-auth signed-bearer-token`"
|
||||
);
|
||||
}
|
||||
let token_file = self.ws_token_file.context(
|
||||
"`--ws-token-file` is required when `--ws-auth capability-token` is set",
|
||||
)?;
|
||||
Some(AppServerWebsocketAuthConfig::CapabilityToken {
|
||||
token_file: absolute_path_arg("--ws-token-file", token_file)?,
|
||||
})
|
||||
let source = match (self.ws_token_file, self.ws_token_sha256) {
|
||||
(Some(_), Some(_)) => {
|
||||
anyhow::bail!(
|
||||
"`--ws-token-file` and `--ws-token-sha256` are mutually exclusive"
|
||||
);
|
||||
}
|
||||
(Some(token_file), None) => {
|
||||
AppServerWebsocketCapabilityTokenSource::TokenFile {
|
||||
token_file: absolute_path_arg("--ws-token-file", token_file)?,
|
||||
}
|
||||
}
|
||||
(None, Some(token_sha256)) => {
|
||||
AppServerWebsocketCapabilityTokenSource::TokenSha256 {
|
||||
token_sha256: sha256_digest_arg("--ws-token-sha256", &token_sha256)?,
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
anyhow::bail!(
|
||||
"`--ws-token-file` or `--ws-token-sha256` is required when `--ws-auth capability-token` is set"
|
||||
);
|
||||
}
|
||||
};
|
||||
Some(AppServerWebsocketAuthConfig::CapabilityToken { source })
|
||||
}
|
||||
Some(WebsocketAuthCliMode::SignedBearerToken) => {
|
||||
if self.ws_token_file.is_some() {
|
||||
if self.ws_token_file.is_some() || self.ws_token_sha256.is_some() {
|
||||
anyhow::bail!(
|
||||
"`--ws-token-file` requires `--ws-auth capability-token`, not `signed-bearer-token`"
|
||||
"`--ws-token-file` and `--ws-token-sha256` require `--ws-auth capability-token`, not `signed-bearer-token`"
|
||||
);
|
||||
}
|
||||
let shared_secret_file = self.ws_shared_secret_file.context(
|
||||
@@ -174,6 +201,7 @@ impl AppServerWebsocketAuthArgs {
|
||||
}
|
||||
None => {
|
||||
if self.ws_token_file.is_some()
|
||||
|| self.ws_token_sha256.is_some()
|
||||
|| self.ws_shared_secret_file.is_some()
|
||||
|| self.ws_issuer.is_some()
|
||||
|| self.ws_audience.is_some()
|
||||
@@ -195,12 +223,19 @@ pub(crate) fn policy_from_settings(
|
||||
settings: &AppServerWebsocketAuthSettings,
|
||||
) -> io::Result<WebsocketAuthPolicy> {
|
||||
let mode = match settings.config.as_ref() {
|
||||
Some(AppServerWebsocketAuthConfig::CapabilityToken { token_file }) => {
|
||||
let token = read_trimmed_secret(token_file.as_ref())?;
|
||||
Some(WebsocketAuthMode::CapabilityToken {
|
||||
token_sha256: sha256_digest(token.as_bytes()),
|
||||
})
|
||||
}
|
||||
Some(AppServerWebsocketAuthConfig::CapabilityToken { source }) => match source {
|
||||
AppServerWebsocketCapabilityTokenSource::TokenFile { token_file } => {
|
||||
let token = read_trimmed_secret(token_file.as_ref())?;
|
||||
Some(WebsocketAuthMode::CapabilityToken {
|
||||
token_sha256: sha256_digest(token.as_bytes()),
|
||||
})
|
||||
}
|
||||
AppServerWebsocketCapabilityTokenSource::TokenSha256 { token_sha256 } => {
|
||||
Some(WebsocketAuthMode::CapabilityToken {
|
||||
token_sha256: *token_sha256,
|
||||
})
|
||||
}
|
||||
},
|
||||
Some(AppServerWebsocketAuthConfig::SignedBearerToken {
|
||||
shared_secret_file,
|
||||
issuer,
|
||||
@@ -387,6 +422,30 @@ fn absolute_path_arg(flag_name: &str, path: PathBuf) -> anyhow::Result<AbsoluteP
|
||||
AbsolutePathBuf::try_from(path).with_context(|| format!("{flag_name} must be an absolute path"))
|
||||
}
|
||||
|
||||
fn sha256_digest_arg(flag_name: &str, value: &str) -> anyhow::Result<[u8; 32]> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.len() != 64 {
|
||||
anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest");
|
||||
}
|
||||
|
||||
let mut digest = [0u8; 32];
|
||||
for (index, pair) in trimmed.as_bytes().chunks_exact(2).enumerate() {
|
||||
let high = hex_nibble(flag_name, pair[0])?;
|
||||
let low = hex_nibble(flag_name, pair[1])?;
|
||||
digest[index] = (high << 4) | low;
|
||||
}
|
||||
Ok(digest)
|
||||
}
|
||||
|
||||
fn hex_nibble(flag_name: &str, byte: u8) -> anyhow::Result<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Ok(byte - b'0'),
|
||||
b'a'..=b'f' => Ok(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Ok(byte - b'A' + 10),
|
||||
_ => anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_digest(input: &[u8]) -> [u8; 32] {
|
||||
let mut digest = [0u8; 32];
|
||||
digest.copy_from_slice(&Sha256::digest(input));
|
||||
@@ -403,6 +462,7 @@ fn unauthorized(message: &'static str) -> WebsocketAuthError {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use hmac::Hmac;
|
||||
@@ -443,19 +503,98 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_token_args_require_token_file() {
|
||||
fn capability_token_args_require_token_file_or_hash() {
|
||||
let err = AppServerWebsocketAuthArgs {
|
||||
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
|
||||
..Default::default()
|
||||
}
|
||||
.try_into_settings()
|
||||
.expect_err("capability-token mode should require a token file");
|
||||
.expect_err("capability-token mode should require a token source");
|
||||
assert!(
|
||||
err.to_string().contains("--ws-token-file"),
|
||||
err.to_string().contains("--ws-token-file")
|
||||
&& err.to_string().contains("--ws-token-sha256"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_token_args_accept_token_hash() {
|
||||
let settings = AppServerWebsocketAuthArgs {
|
||||
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
|
||||
ws_token_sha256: Some("ab".repeat(32)),
|
||||
..Default::default()
|
||||
}
|
||||
.try_into_settings()
|
||||
.expect("capability-token hash args should parse");
|
||||
|
||||
assert_eq!(
|
||||
settings,
|
||||
AppServerWebsocketAuthSettings {
|
||||
config: Some(AppServerWebsocketAuthConfig::CapabilityToken {
|
||||
source: AppServerWebsocketCapabilityTokenSource::TokenSha256 {
|
||||
token_sha256: [0xab; 32],
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_token_args_reject_multiple_token_sources() {
|
||||
let err = AppServerWebsocketAuthArgs {
|
||||
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
|
||||
ws_token_file: Some(PathBuf::from("/tmp/token")),
|
||||
ws_token_sha256: Some("ab".repeat(32)),
|
||||
..Default::default()
|
||||
}
|
||||
.try_into_settings()
|
||||
.expect_err("capability-token mode should reject multiple token sources");
|
||||
assert!(
|
||||
err.to_string().contains("mutually exclusive"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_token_args_reject_malformed_token_hash() {
|
||||
let err = AppServerWebsocketAuthArgs {
|
||||
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
|
||||
ws_token_sha256: Some("not-a-sha256".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.try_into_settings()
|
||||
.expect_err("capability-token mode should reject malformed token hashes");
|
||||
assert!(
|
||||
err.to_string().contains("64-character hex"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_token_hash_policy_authorizes_matching_bearer_token() {
|
||||
let settings = AppServerWebsocketAuthSettings {
|
||||
config: Some(AppServerWebsocketAuthConfig::CapabilityToken {
|
||||
source: AppServerWebsocketCapabilityTokenSource::TokenSha256 {
|
||||
token_sha256: sha256_digest(b"super-secret-token"),
|
||||
},
|
||||
}),
|
||||
};
|
||||
let policy = policy_from_settings(&settings).expect("hash policy should build");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer super-secret-token"),
|
||||
);
|
||||
authorize_upgrade(&headers, &policy).expect("matching token should authorize");
|
||||
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer wrong-token"),
|
||||
);
|
||||
let err = authorize_upgrade(&headers, &policy).expect_err("wrong token should fail");
|
||||
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_bearer_args_require_mode_when_mode_specific_flags_are_set() {
|
||||
let err = AppServerWebsocketAuthArgs {
|
||||
|
||||
@@ -96,6 +96,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson {
|
||||
account_id: account_id.map(str::to_string),
|
||||
}),
|
||||
last_refresh: Some(chrono::Utc::now()),
|
||||
agent_identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -971,6 +971,7 @@ mod tests {
|
||||
account_id: Some("account_id".to_string()),
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ pub fn write_chatgpt_auth(
|
||||
openai_api_key: None,
|
||||
tokens: Some(tokens),
|
||||
last_refresh,
|
||||
agent_identity: None,
|
||||
};
|
||||
|
||||
save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json")
|
||||
|
||||
@@ -117,6 +117,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
|
||||
openai_api_key: Some("test-api-key".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
},
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
@@ -66,8 +66,9 @@ use tokio::time::timeout;
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const CONNECTOR_ID: &str = "calendar";
|
||||
const CONNECTOR_NAME: &str = "Calendar";
|
||||
const TOOL_NAMESPACE: &str = "mcp__codex_apps__calendar";
|
||||
const CALLABLE_TOOL_NAME: &str = "_confirm_action";
|
||||
const TOOL_NAME: &str = "calendar_confirm_action";
|
||||
const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar_confirm_action";
|
||||
const TOOL_CALL_ID: &str = "call-calendar-confirm";
|
||||
const ELICITATION_MESSAGE: &str = "Allow this request?";
|
||||
|
||||
@@ -85,9 +86,10 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> {
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(
|
||||
responses::ev_function_call_with_namespace(
|
||||
TOOL_CALL_ID,
|
||||
QUALIFIED_TOOL_NAME,
|
||||
TOOL_NAMESPACE,
|
||||
CALLABLE_TOOL_NAME,
|
||||
&tool_call_arguments,
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::MemoryResetResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_state::Stage1JobClaimOutcome;
|
||||
use codex_state::StateRuntime;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_reset_clears_memory_files_and_rows_preserves_threads() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path())?;
|
||||
let state_db = init_state_db(codex_home.path()).await?;
|
||||
|
||||
let memory_root = codex_home.path().join("memories");
|
||||
tokio::fs::create_dir_all(memory_root.join("rollout_summaries")).await?;
|
||||
tokio::fs::write(memory_root.join("MEMORY.md"), "stale memory\n").await?;
|
||||
tokio::fs::write(
|
||||
memory_root.join("rollout_summaries").join("stale.md"),
|
||||
"stale rollout summary\n",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let thread_id = seed_stage1_output(&state_db, codex_home.path()).await?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request("memory/reset", /*params*/ None)
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: MemoryResetResponse = to_response::<MemoryResetResponse>(response)?;
|
||||
|
||||
let stage1_outputs = state_db.list_stage1_outputs_for_global(/*n*/ 10).await?;
|
||||
assert_eq!(stage1_outputs, Vec::new());
|
||||
assert_eq!(
|
||||
state_db.get_thread_memory_mode(thread_id).await?.as_deref(),
|
||||
Some("enabled")
|
||||
);
|
||||
|
||||
let mut remaining_entries = tokio::fs::read_dir(&memory_root).await?;
|
||||
assert!(
|
||||
remaining_entries.next_entry().await?.is_none(),
|
||||
"memory root should be empty after reset"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_stage1_output(state_db: &Arc<StateRuntime>, codex_home: &Path) -> Result<ThreadId> {
|
||||
let now = Utc::now();
|
||||
let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
|
||||
let worker_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
|
||||
let mut builder = ThreadMetadataBuilder::new(
|
||||
thread_id,
|
||||
codex_home.join("sessions").join("test.jsonl"),
|
||||
now,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
builder.updated_at = Some(now);
|
||||
builder.cwd = codex_home.to_path_buf();
|
||||
let metadata = builder.build("mock_provider");
|
||||
state_db.upsert_thread(&metadata).await?;
|
||||
|
||||
let claim = state_db
|
||||
.try_claim_stage1_job(
|
||||
thread_id,
|
||||
worker_id,
|
||||
now.timestamp(),
|
||||
/*lease_seconds*/ 3600,
|
||||
/*max_running_jobs*/ 64,
|
||||
)
|
||||
.await?;
|
||||
let Stage1JobClaimOutcome::Claimed { ownership_token } = claim else {
|
||||
anyhow::bail!("unexpected stage1 claim outcome: {claim:?}");
|
||||
};
|
||||
assert!(
|
||||
state_db
|
||||
.mark_stage1_job_succeeded(
|
||||
thread_id,
|
||||
ownership_token.as_str(),
|
||||
now.timestamp(),
|
||||
"raw memory",
|
||||
"rollout summary",
|
||||
/*rollout_slug*/ None,
|
||||
)
|
||||
.await?,
|
||||
"stage1 success should be recorded"
|
||||
);
|
||||
state_db
|
||||
.enqueue_global_consolidation(now.timestamp())
|
||||
.await?;
|
||||
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
async fn init_state_db(codex_home: &Path) -> Result<Arc<StateRuntime>> {
|
||||
let state_db = StateRuntime::init(codex_home.to_path_buf(), "mock_provider".into()).await?;
|
||||
state_db
|
||||
.mark_backfill_complete(/*last_watermark*/ None)
|
||||
.await?;
|
||||
Ok(state_db)
|
||||
}
|
||||
|
||||
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
model_provider = "mock_provider"
|
||||
suppress_unstable_features_warning = true
|
||||
|
||||
[features]
|
||||
sqlite = true
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "http://127.0.0.1:9/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#,
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ mod mcp_resource;
|
||||
mod mcp_server_elicitation;
|
||||
mod mcp_server_status;
|
||||
mod mcp_tool;
|
||||
mod memory_reset;
|
||||
mod model_list;
|
||||
mod output_schema;
|
||||
mod plan_item;
|
||||
|
||||
@@ -45,6 +45,7 @@ codex-state = { workspace = true }
|
||||
codex-stdio-to-uds = { workspace = true }
|
||||
codex-terminal-detection = { workspace = true }
|
||||
codex-tui = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-path = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
|
||||
@@ -18,7 +18,10 @@ use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies;
|
||||
use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_sandboxing::seatbelt::create_seatbelt_command_args;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
@@ -39,6 +42,7 @@ pub async fn run_command_under_seatbelt(
|
||||
) -> anyhow::Result<()> {
|
||||
let SeatbeltCommand {
|
||||
full_auto,
|
||||
allow_unix_sockets,
|
||||
log_denials,
|
||||
config_overrides,
|
||||
command,
|
||||
@@ -50,6 +54,7 @@ pub async fn run_command_under_seatbelt(
|
||||
codex_linux_sandbox_exe,
|
||||
SandboxType::Seatbelt,
|
||||
log_denials,
|
||||
&allow_unix_sockets,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -78,6 +83,7 @@ pub async fn run_command_under_landlock(
|
||||
codex_linux_sandbox_exe,
|
||||
SandboxType::Landlock,
|
||||
/*log_denials*/ false,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -98,6 +104,7 @@ pub async fn run_command_under_windows(
|
||||
codex_linux_sandbox_exe,
|
||||
SandboxType::Windows,
|
||||
/*log_denials*/ false,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -116,6 +123,8 @@ async fn run_command_under_sandbox(
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
sandbox_type: SandboxType,
|
||||
log_denials: bool,
|
||||
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
|
||||
allow_unix_sockets: &[AbsolutePathBuf],
|
||||
) -> anyhow::Result<()> {
|
||||
let config = load_debug_sandbox_config(
|
||||
config_overrides
|
||||
@@ -252,14 +261,15 @@ async fn run_command_under_sandbox(
|
||||
let mut child = match sandbox_type {
|
||||
#[cfg(target_os = "macos")]
|
||||
SandboxType::Seatbelt => {
|
||||
let args = create_seatbelt_command_args_for_policies(
|
||||
let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams {
|
||||
command,
|
||||
&config.permissions.file_system_sandbox_policy,
|
||||
config.permissions.network_sandbox_policy,
|
||||
sandbox_policy_cwd.as_path(),
|
||||
/*enforce_managed_network*/ false,
|
||||
network.as_ref(),
|
||||
);
|
||||
file_system_sandbox_policy: &config.permissions.file_system_sandbox_policy,
|
||||
network_sandbox_policy: config.permissions.network_sandbox_policy,
|
||||
sandbox_policy_cwd: sandbox_policy_cwd.as_path(),
|
||||
enforce_managed_network: false,
|
||||
network: network.as_ref(),
|
||||
extra_allow_unix_sockets: allow_unix_sockets,
|
||||
});
|
||||
let network_policy = config.permissions.network_sandbox_policy;
|
||||
spawn_debug_sandbox_child(
|
||||
PathBuf::from("/usr/bin/sandbox-exec"),
|
||||
|
||||
@@ -3,6 +3,7 @@ mod exit_status;
|
||||
pub(crate) mod login;
|
||||
|
||||
use clap::Parser;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
|
||||
pub use debug_sandbox::run_command_under_landlock;
|
||||
@@ -22,6 +23,10 @@ pub struct SeatbeltCommand {
|
||||
#[arg(long = "full-auto", default_value_t = false)]
|
||||
pub full_auto: bool,
|
||||
|
||||
/// Allow the sandboxed command to bind/connect AF_UNIX sockets rooted at this path. Relative paths are resolved against the current directory. Repeat to allow multiple paths.
|
||||
#[arg(long = "allow-unix-socket", value_parser = parse_allow_unix_socket_path)]
|
||||
pub allow_unix_sockets: Vec<AbsolutePathBuf>,
|
||||
|
||||
/// While the command runs, capture macOS sandbox denials via `log stream` and print them after exit
|
||||
#[arg(long = "log-denials", default_value_t = false)]
|
||||
pub log_denials: bool,
|
||||
@@ -34,6 +39,11 @@ pub struct SeatbeltCommand {
|
||||
pub command: Vec<String>,
|
||||
}
|
||||
|
||||
fn parse_allow_unix_socket_path(raw: &str) -> Result<AbsolutePathBuf, String> {
|
||||
AbsolutePathBuf::relative_to_current_dir(raw)
|
||||
.map_err(|err| format!("invalid path {raw}: {err}"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct LandlockCommand {
|
||||
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
|
||||
|
||||
@@ -1269,7 +1269,7 @@ async fn run_debug_clear_memories_command(
|
||||
let state_db =
|
||||
StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone())
|
||||
.await?;
|
||||
state_db.reset_memory_data_for_fresh_start().await?;
|
||||
state_db.clear_memory_data().await?;
|
||||
cleared_state_db = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,12 +125,6 @@ INSERT INTO jobs (
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
assert_eq!(memory_jobs_count, 0);
|
||||
|
||||
let memory_mode: String = sqlx::query_scalar("SELECT memory_mode FROM threads WHERE id = ?")
|
||||
.bind(thread_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
assert_eq!(memory_mode, "disabled");
|
||||
assert!(!memory_root.exists());
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -14,6 +14,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
deno_core_icudata = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use codex_protocol::ToolName;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
@@ -129,6 +130,7 @@ pub enum CodeModeToolKind {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
pub tool_name: ToolName,
|
||||
pub description: String,
|
||||
pub kind: CodeModeToolKind,
|
||||
pub input_schema: Option<JsonValue>,
|
||||
@@ -269,11 +271,15 @@ pub fn build_exec_tool_description(
|
||||
for tool in enabled_tools {
|
||||
let name = tool.name.as_str();
|
||||
let nested_description = render_code_mode_sample_for_definition(tool);
|
||||
let next_namespace = namespace_descriptions
|
||||
.get(name)
|
||||
let namespace_description = tool
|
||||
.tool_name
|
||||
.namespace
|
||||
.as_ref()
|
||||
.and_then(|namespace| namespace_descriptions.get(namespace));
|
||||
let next_namespace = namespace_description
|
||||
.map(|namespace_description| namespace_description.name.as_str());
|
||||
if next_namespace != current_namespace {
|
||||
if let Some(namespace_description) = namespace_descriptions.get(name) {
|
||||
if let Some(namespace_description) = namespace_description {
|
||||
let namespace_description_text = namespace_description.description.trim();
|
||||
if !namespace_description_text.is_empty() {
|
||||
nested_tool_sections.push(format!(
|
||||
@@ -346,7 +352,7 @@ pub fn augment_tool_definition(mut definition: ToolDefinition) -> ToolDefinition
|
||||
|
||||
pub fn enabled_tool_metadata(definition: &ToolDefinition) -> EnabledToolMetadata {
|
||||
EnabledToolMetadata {
|
||||
tool_name: definition.name.clone(),
|
||||
tool_name: definition.tool_name.clone(),
|
||||
global_name: normalize_code_mode_identifier(&definition.name),
|
||||
description: definition.description.clone(),
|
||||
kind: definition.kind,
|
||||
@@ -355,7 +361,7 @@ pub fn enabled_tool_metadata(definition: &ToolDefinition) -> EnabledToolMetadata
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct EnabledToolMetadata {
|
||||
pub tool_name: String,
|
||||
pub tool_name: ToolName,
|
||||
pub global_name: String,
|
||||
pub description: String,
|
||||
pub kind: CodeModeToolKind,
|
||||
@@ -706,6 +712,7 @@ mod tests {
|
||||
use super::build_exec_tool_description;
|
||||
use super::normalize_code_mode_identifier;
|
||||
use super::parse_exec_source;
|
||||
use codex_protocol::ToolName;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json::json;
|
||||
@@ -770,6 +777,7 @@ mod tests {
|
||||
fn augment_tool_definition_appends_typed_declaration() {
|
||||
let definition = ToolDefinition {
|
||||
name: "hidden_dynamic_tool".to_string(),
|
||||
tool_name: ToolName::plain("hidden_dynamic_tool"),
|
||||
description: "Test tool".to_string(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: Some(json!({
|
||||
@@ -798,6 +806,7 @@ mod tests {
|
||||
fn augment_tool_definition_includes_property_descriptions_as_comments() {
|
||||
let definition = ToolDefinition {
|
||||
name: "weather_tool".to_string(),
|
||||
tool_name: ToolName::plain("weather_tool"),
|
||||
description: "Weather tool".to_string(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: Some(json!({
|
||||
@@ -846,6 +855,7 @@ mod tests {
|
||||
let description = build_exec_tool_description(
|
||||
&[ToolDefinition {
|
||||
name: "foo".to_string(),
|
||||
tool_name: ToolName::plain("foo"),
|
||||
description: "bar".to_string(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: None,
|
||||
@@ -870,26 +880,18 @@ bar"
|
||||
|
||||
#[test]
|
||||
fn code_mode_only_description_groups_namespace_instructions_once() {
|
||||
let namespace_descriptions = BTreeMap::from([
|
||||
(
|
||||
"mcp__sample__alpha".to_string(),
|
||||
ToolNamespaceDescription {
|
||||
name: "mcp__sample".to_string(),
|
||||
description: "Shared namespace guidance.".to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"mcp__sample__beta".to_string(),
|
||||
ToolNamespaceDescription {
|
||||
name: "mcp__sample".to_string(),
|
||||
description: "Shared namespace guidance.".to_string(),
|
||||
},
|
||||
),
|
||||
]);
|
||||
let namespace_descriptions = BTreeMap::from([(
|
||||
"mcp__sample__".to_string(),
|
||||
ToolNamespaceDescription {
|
||||
name: "mcp__sample".to_string(),
|
||||
description: "Shared namespace guidance.".to_string(),
|
||||
},
|
||||
)]);
|
||||
let description = build_exec_tool_description(
|
||||
&[
|
||||
ToolDefinition {
|
||||
name: "mcp__sample__alpha".to_string(),
|
||||
tool_name: ToolName::namespaced("mcp__sample__", "alpha"),
|
||||
description: "First tool".to_string(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: Some(json!({
|
||||
@@ -905,6 +907,7 @@ bar"
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "mcp__sample__beta".to_string(),
|
||||
tool_name: ToolName::namespaced("mcp__sample__", "beta"),
|
||||
description: "Second tool".to_string(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: Some(json!({
|
||||
@@ -935,7 +938,7 @@ bar"
|
||||
#[test]
|
||||
fn code_mode_only_description_omits_empty_namespace_sections() {
|
||||
let namespace_descriptions = BTreeMap::from([(
|
||||
"mcp__sample__alpha".to_string(),
|
||||
"mcp__sample__".to_string(),
|
||||
ToolNamespaceDescription {
|
||||
name: "mcp__sample".to_string(),
|
||||
description: String::new(),
|
||||
@@ -944,6 +947,7 @@ bar"
|
||||
let description = build_exec_tool_description(
|
||||
&[ToolDefinition {
|
||||
name: "mcp__sample__alpha".to_string(),
|
||||
tool_name: ToolName::namespaced("mcp__sample__", "alpha"),
|
||||
description: "First tool".to_string(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: Some(json!({
|
||||
@@ -969,6 +973,7 @@ bar"
|
||||
fn code_mode_only_description_renders_shared_mcp_types_once() {
|
||||
let first_tool = augment_tool_definition(ToolDefinition {
|
||||
name: "mcp__sample__alpha".to_string(),
|
||||
tool_name: ToolName::namespaced("mcp__sample__", "alpha"),
|
||||
description: "First tool".to_string(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: Some(json!({
|
||||
@@ -1002,6 +1007,7 @@ bar"
|
||||
});
|
||||
let second_tool = augment_tool_definition(ToolDefinition {
|
||||
name: "mcp__sample__beta".to_string(),
|
||||
tool_name: ToolName::namespaced("mcp__sample__", "beta"),
|
||||
description: "Second tool".to_string(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: Some(json!({
|
||||
@@ -1038,6 +1044,7 @@ bar"
|
||||
&[
|
||||
ToolDefinition {
|
||||
name: first_tool.name,
|
||||
tool_name: first_tool.tool_name,
|
||||
description: "First tool".to_string(),
|
||||
kind: first_tool.kind,
|
||||
input_schema: first_tool.input_schema,
|
||||
@@ -1045,6 +1052,7 @@ bar"
|
||||
},
|
||||
ToolDefinition {
|
||||
name: second_tool.name,
|
||||
tool_name: second_tool.tool_name,
|
||||
description: "Second tool".to_string(),
|
||||
kind: second_tool.kind,
|
||||
input_schema: second_tool.input_schema,
|
||||
|
||||
@@ -15,7 +15,13 @@ pub(super) fn tool_callback(
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let tool_name = args.data().to_rust_string_lossy(scope);
|
||||
let tool_index = match args.data().to_rust_string_lossy(scope).parse::<usize>() {
|
||||
Ok(tool_index) => tool_index,
|
||||
Err(_) => {
|
||||
throw_type_error(scope, "invalid tool callback data");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let input = if args.length() == 0 {
|
||||
Ok(None)
|
||||
} else {
|
||||
@@ -36,6 +42,22 @@ pub(super) fn tool_callback(
|
||||
let promise = resolver.get_promise(scope);
|
||||
|
||||
let resolver = v8::Global::new(scope, resolver);
|
||||
let tool_name = {
|
||||
let Some(state) = scope.get_slot::<RuntimeState>() else {
|
||||
throw_type_error(scope, "runtime state unavailable");
|
||||
return;
|
||||
};
|
||||
let Some(tool_name) = state
|
||||
.enabled_tools
|
||||
.get(tool_index)
|
||||
.map(|tool| tool.tool_name.clone())
|
||||
else {
|
||||
throw_type_error(scope, "tool callback data is out of range");
|
||||
return;
|
||||
};
|
||||
tool_name
|
||||
};
|
||||
|
||||
let Some(state) = scope.get_slot_mut::<RuntimeState>() else {
|
||||
throw_type_error(scope, "runtime state unavailable");
|
||||
return;
|
||||
|
||||
@@ -53,10 +53,10 @@ fn build_tools_object<'s>(
|
||||
.map(|state| state.enabled_tools.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
for tool in enabled_tools {
|
||||
for (tool_index, tool) in enabled_tools.iter().enumerate() {
|
||||
let name = v8::String::new(scope, &tool.global_name)
|
||||
.ok_or_else(|| "failed to allocate tool name".to_string())?;
|
||||
let function = tool_function(scope, &tool.tool_name)?;
|
||||
let function = tool_function(scope, tool_index)?;
|
||||
tools.set(scope, name.into(), function.into());
|
||||
}
|
||||
Ok(tools)
|
||||
@@ -116,9 +116,9 @@ where
|
||||
|
||||
fn tool_function<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
tool_name: &str,
|
||||
tool_index: usize,
|
||||
) -> Result<v8::Local<'s, v8::Function>, String> {
|
||||
let data = v8::String::new(scope, tool_name)
|
||||
let data = v8::String::new(scope, &tool_index.to_string())
|
||||
.ok_or_else(|| "failed to allocate tool callback data".to_string())?;
|
||||
let template = v8::FunctionTemplate::builder(tool_callback)
|
||||
.data(data.into())
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::sync::OnceLock;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::thread;
|
||||
|
||||
use codex_protocol::ToolName;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@@ -62,7 +63,7 @@ pub(crate) enum TurnMessage {
|
||||
ToolCall {
|
||||
cell_id: String,
|
||||
id: String,
|
||||
name: String,
|
||||
name: ToolName,
|
||||
input: Option<JsonValue>,
|
||||
},
|
||||
Notify {
|
||||
@@ -87,7 +88,7 @@ pub(crate) enum RuntimeEvent {
|
||||
YieldRequested,
|
||||
ToolCall {
|
||||
id: String,
|
||||
name: String,
|
||||
name: ToolName,
|
||||
input: Option<JsonValue>,
|
||||
},
|
||||
Notify {
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::ToolName;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -26,7 +27,7 @@ use crate::runtime::spawn_runtime;
|
||||
pub trait CodeModeTurnHost: Send + Sync {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
tool_name: String,
|
||||
tool_name: ToolName,
|
||||
input: Option<JsonValue>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String>;
|
||||
|
||||
@@ -57,7 +57,7 @@ pub use crate::error::ApiError;
|
||||
pub use crate::files::upload_local_file;
|
||||
pub use crate::provider::Provider;
|
||||
pub use crate::provider::RetryConfig;
|
||||
pub use crate::provider::is_azure_responses_wire_base_url;
|
||||
pub use crate::provider::is_azure_responses_provider;
|
||||
pub use crate::requests::Compression;
|
||||
pub use crate::sse::stream_from_fixture;
|
||||
pub use crate::telemetry::SseTelemetry;
|
||||
|
||||
@@ -86,7 +86,7 @@ impl Provider {
|
||||
}
|
||||
|
||||
pub fn is_azure_responses_endpoint(&self) -> bool {
|
||||
is_azure_responses_wire_base_url(&self.name, Some(&self.base_url))
|
||||
is_azure_responses_provider(&self.name, Some(&self.base_url))
|
||||
}
|
||||
|
||||
pub fn websocket_url_for_path(&self, path: &str) -> Result<Url, url::ParseError> {
|
||||
@@ -103,21 +103,20 @@ impl Provider {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_azure_responses_wire_base_url(name: &str, base_url: Option<&str>) -> bool {
|
||||
pub fn is_azure_responses_provider(name: &str, base_url: Option<&str>) -> bool {
|
||||
if name.eq_ignore_ascii_case("azure") {
|
||||
return true;
|
||||
true
|
||||
} else if let Some(base_url) = base_url {
|
||||
matches_azure_responses_base_url(base_url)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
let Some(base_url) = base_url else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let base = base_url.to_ascii_lowercase();
|
||||
base.contains("openai.azure.") || matches_azure_responses_base_url(&base)
|
||||
}
|
||||
|
||||
fn matches_azure_responses_base_url(base_url: &str) -> bool {
|
||||
const AZURE_MARKERS: [&str; 5] = [
|
||||
let base_url = base_url.to_ascii_lowercase();
|
||||
const AZURE_MARKERS: [&str; 6] = [
|
||||
"openai.azure.",
|
||||
"cognitiveservices.azure.",
|
||||
"aoai.azure.",
|
||||
"azure-api.",
|
||||
@@ -144,12 +143,12 @@ mod tests {
|
||||
|
||||
for base_url in positive_cases {
|
||||
assert!(
|
||||
is_azure_responses_wire_base_url("test", Some(base_url)),
|
||||
is_azure_responses_provider("test", Some(base_url)),
|
||||
"expected {base_url} to be detected as Azure"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(is_azure_responses_wire_base_url(
|
||||
assert!(is_azure_responses_provider(
|
||||
"Azure",
|
||||
Some("https://example.com")
|
||||
));
|
||||
@@ -162,7 +161,7 @@ mod tests {
|
||||
|
||||
for base_url in negative_cases {
|
||||
assert!(
|
||||
!is_azure_responses_wire_base_url("test", Some(base_url)),
|
||||
!is_azure_responses_provider("test", Some(base_url)),
|
||||
"expected {base_url} not to be detected as Azure"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ pub use mcp::with_codex_apps_mcp;
|
||||
pub use mcp_connection_manager::CodexAppsToolsCacheKey;
|
||||
pub use mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_META_CAPABILITY;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD;
|
||||
pub use mcp_connection_manager::McpConnectionManager;
|
||||
pub use mcp_connection_manager::SandboxState;
|
||||
|
||||
@@ -36,6 +36,7 @@ use codex_async_utils::CancelErr;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_config::Constrained;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_protocol::ToolName;
|
||||
use codex_protocol::approvals::ElicitationRequest;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
@@ -155,6 +156,12 @@ pub struct ToolInfo {
|
||||
pub connector_description: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolInfo {
|
||||
pub fn canonical_tool_name(&self) -> ToolName {
|
||||
ToolName::namespaced(self.callable_namespace.clone(), self.callable_name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams";
|
||||
|
||||
pub fn declared_openai_file_input_param_names(
|
||||
@@ -433,6 +440,7 @@ struct ManagedClient {
|
||||
tool_timeout: Option<Duration>,
|
||||
server_instructions: Option<String>,
|
||||
server_supports_sandbox_state_capability: bool,
|
||||
server_supports_sandbox_state_meta_capability: bool,
|
||||
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
||||
}
|
||||
|
||||
@@ -647,6 +655,10 @@ pub const MCP_SANDBOX_STATE_CAPABILITY: &str = "codex/sandbox-state";
|
||||
/// When used, the `params` field of the notification is [`SandboxState`].
|
||||
pub const MCP_SANDBOX_STATE_METHOD: &str = "codex/sandbox-state/update";
|
||||
|
||||
/// MCP server capability indicating that Codex should include [`SandboxState`]
|
||||
/// in tool-call request `_meta` under this key.
|
||||
pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SandboxState {
|
||||
@@ -1142,6 +1154,16 @@ impl McpConnectionManager {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn server_supports_sandbox_state_meta_capability(
|
||||
&self,
|
||||
server: &str,
|
||||
) -> Result<bool> {
|
||||
Ok(self
|
||||
.client_by_name(server)
|
||||
.await?
|
||||
.server_supports_sandbox_state_meta_capability)
|
||||
}
|
||||
|
||||
/// List resources from the specified server.
|
||||
pub async fn list_resources(
|
||||
&self,
|
||||
@@ -1191,14 +1213,11 @@ impl McpConnectionManager {
|
||||
.with_context(|| format!("resources/read failed for `{server}` ({uri})"))
|
||||
}
|
||||
|
||||
pub async fn resolve_tool_info(&self, name: &str, namespace: Option<&str>) -> Option<ToolInfo> {
|
||||
let qualified_name = match namespace {
|
||||
Some(namespace) if name.starts_with(namespace) => name.to_string(),
|
||||
Some(namespace) => format!("{namespace}{name}"),
|
||||
None => name.to_string(),
|
||||
};
|
||||
|
||||
self.list_all_tools().await.get(&qualified_name).cloned()
|
||||
pub async fn resolve_tool_info(&self, tool_name: &ToolName) -> Option<ToolInfo> {
|
||||
let all_tools = self.list_all_tools().await;
|
||||
all_tools
|
||||
.into_values()
|
||||
.find(|tool| tool.canonical_tool_name() == *tool_name)
|
||||
}
|
||||
|
||||
pub async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> {
|
||||
@@ -1479,6 +1498,12 @@ async fn start_server_task(
|
||||
.as_ref()
|
||||
.and_then(|exp| exp.get(MCP_SANDBOX_STATE_CAPABILITY))
|
||||
.is_some();
|
||||
let server_supports_sandbox_state_meta_capability = initialize_result
|
||||
.capabilities
|
||||
.experimental
|
||||
.as_ref()
|
||||
.and_then(|exp| exp.get(MCP_SANDBOX_STATE_META_CAPABILITY))
|
||||
.is_some();
|
||||
let list_start = Instant::now();
|
||||
let fetch_start = Instant::now();
|
||||
let tools = list_tools_for_client_uncached(
|
||||
@@ -1515,6 +1540,7 @@ async fn start_server_task(
|
||||
tool_filter,
|
||||
server_instructions: initialize_result.instructions,
|
||||
server_supports_sandbox_state_capability,
|
||||
server_supports_sandbox_state_meta_capability,
|
||||
codex_apps_tools_cache_context,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use codex_protocol::ToolName;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::McpAuthStatus;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -646,6 +647,42 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() {
|
||||
assert_eq!(tool.callable_name, "calendar_create_event");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_tool_info_accepts_canonical_namespaced_tool_names() {
|
||||
let startup_tools = vec![create_test_tool("rmcp", "echo")];
|
||||
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
|
||||
.boxed()
|
||||
.shared();
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
|
||||
let sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy());
|
||||
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, &sandbox_policy);
|
||||
manager.clients.insert(
|
||||
"rmcp".to_string(),
|
||||
AsyncManagedClient {
|
||||
client: pending_client,
|
||||
startup_snapshot: Some(startup_tools),
|
||||
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
let tool = manager
|
||||
.resolve_tool_info(&ToolName::namespaced("mcp__rmcp__", "echo"))
|
||||
.await
|
||||
.expect("split MCP tool namespace and name should resolve");
|
||||
|
||||
let expected = ("rmcp", "mcp__rmcp__", "echo", "echo");
|
||||
assert_eq!(
|
||||
(
|
||||
tool.server_name.as_str(),
|
||||
tool.callable_namespace.as_str(),
|
||||
tool.callable_name.as_str(),
|
||||
tool.tool.name.as_ref(),
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() {
|
||||
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
|
||||
|
||||
@@ -53,6 +53,7 @@ codex_rust_crate(
|
||||
"//codex-rs/linux-sandbox:codex-linux-sandbox",
|
||||
"//codex-rs/rmcp-client:test_stdio_server",
|
||||
"//codex-rs/rmcp-client:test_streamable_http_server",
|
||||
"//codex-rs/responses-api-proxy:codex-responses-api-proxy",
|
||||
"//codex-rs/cli:codex",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ codex-login = { workspace = true }
|
||||
codex-mcp = { workspace = true }
|
||||
codex-model-provider-info = { workspace = true }
|
||||
codex-models-manager = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-execpolicy = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
@@ -143,6 +144,7 @@ codex-shell-escalation = { workspace = true }
|
||||
[dev-dependencies]
|
||||
assert_cmd = { workspace = true }
|
||||
assert_matches = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-test-binary-support = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
|
||||
@@ -523,6 +523,9 @@
|
||||
},
|
||||
"web_search_request": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"workspace_dependencies": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -2380,6 +2383,9 @@
|
||||
},
|
||||
"web_search_request": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"workspace_dependencies": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::SecondsFormat;
|
||||
use chrono::Utc;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AgentIdentityAuthRecord;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::create_client;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use ed25519_dalek::pkcs8::DecodePrivateKey;
|
||||
use ed25519_dalek::pkcs8::EncodePrivateKey;
|
||||
use rand::TryRngCore;
|
||||
use rand::rngs::OsRng;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::debug;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
const AGENT_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const AGENT_IDENTITY_BISCUIT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AgentIdentityManager {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
chatgpt_base_url: String,
|
||||
feature_enabled: bool,
|
||||
abom: AgentBillOfMaterials,
|
||||
ensure_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AgentIdentityManager {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AgentIdentityManager")
|
||||
.field("chatgpt_base_url", &self.chatgpt_base_url)
|
||||
.field("feature_enabled", &self.feature_enabled)
|
||||
.field("abom", &self.abom)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct StoredAgentIdentity {
|
||||
pub(crate) binding_id: String,
|
||||
pub(crate) chatgpt_account_id: String,
|
||||
pub(crate) chatgpt_user_id: Option<String>,
|
||||
pub(crate) agent_runtime_id: String,
|
||||
pub(crate) private_key_pkcs8_base64: String,
|
||||
pub(crate) public_key_ssh: String,
|
||||
pub(crate) registered_at: String,
|
||||
pub(crate) abom: AgentBillOfMaterials,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct AgentBillOfMaterials {
|
||||
pub(crate) agent_version: String,
|
||||
pub(crate) agent_harness_id: String,
|
||||
pub(crate) running_location: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RegisterAgentRequest {
|
||||
abom: AgentBillOfMaterials,
|
||||
agent_public_key: String,
|
||||
capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegisterAgentResponse {
|
||||
agent_runtime_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct AgentIdentityBinding {
|
||||
binding_id: String,
|
||||
chatgpt_account_id: String,
|
||||
chatgpt_user_id: Option<String>,
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
struct GeneratedAgentKeyMaterial {
|
||||
private_key_pkcs8_base64: String,
|
||||
public_key_ssh: String,
|
||||
}
|
||||
|
||||
impl AgentIdentityManager {
|
||||
pub(crate) fn new(
|
||||
config: &Config,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
session_source: SessionSource,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
feature_enabled: config.features.enabled(Feature::UseAgentIdentity),
|
||||
abom: build_abom(session_source),
|
||||
ensure_lock: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_enabled(&self) -> bool {
|
||||
self.feature_enabled
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_registered_identity(&self) -> Result<Option<StoredAgentIdentity>> {
|
||||
if !self.feature_enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
debug!("skipping agent identity registration because no auth is available");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(binding) =
|
||||
AgentIdentityBinding::from_auth(&auth, self.auth_manager.forced_chatgpt_workspace_id())
|
||||
else {
|
||||
debug!("skipping agent identity registration because ChatGPT auth is unavailable");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let _guard = self.ensure_lock.lock().await;
|
||||
|
||||
if let Some(stored_identity) = self.load_stored_identity(&auth, &binding)? {
|
||||
info!(
|
||||
agent_runtime_id = %stored_identity.agent_runtime_id,
|
||||
binding_id = %binding.binding_id,
|
||||
"reusing stored agent identity"
|
||||
);
|
||||
return Ok(Some(stored_identity));
|
||||
}
|
||||
|
||||
let stored_identity = self.register_agent_identity(&binding).await?;
|
||||
self.store_identity(&auth, &stored_identity)?;
|
||||
Ok(Some(stored_identity))
|
||||
}
|
||||
|
||||
async fn register_agent_identity(
|
||||
&self,
|
||||
binding: &AgentIdentityBinding,
|
||||
) -> Result<StoredAgentIdentity> {
|
||||
let key_material = generate_agent_key_material()?;
|
||||
let request_body = RegisterAgentRequest {
|
||||
abom: self.abom.clone(),
|
||||
agent_public_key: key_material.public_key_ssh.clone(),
|
||||
capabilities: Vec::new(),
|
||||
};
|
||||
|
||||
let url = agent_registration_url(&self.chatgpt_base_url);
|
||||
let human_biscuit = self.mint_human_biscuit(binding, "POST", &url).await?;
|
||||
let client = create_client();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("X-OpenAI-Authorization", human_biscuit)
|
||||
.json(&request_body)
|
||||
.timeout(AGENT_REGISTRATION_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("failed to send agent identity registration request to {url}")
|
||||
})?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let response_body = response
|
||||
.json::<RegisterAgentResponse>()
|
||||
.await
|
||||
.with_context(|| format!("failed to parse agent identity response from {url}"))?;
|
||||
let stored_identity = StoredAgentIdentity {
|
||||
binding_id: binding.binding_id.clone(),
|
||||
chatgpt_account_id: binding.chatgpt_account_id.clone(),
|
||||
chatgpt_user_id: binding.chatgpt_user_id.clone(),
|
||||
agent_runtime_id: response_body.agent_runtime_id,
|
||||
private_key_pkcs8_base64: key_material.private_key_pkcs8_base64,
|
||||
public_key_ssh: key_material.public_key_ssh,
|
||||
registered_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
abom: self.abom.clone(),
|
||||
};
|
||||
info!(
|
||||
agent_runtime_id = %stored_identity.agent_runtime_id,
|
||||
binding_id = %binding.binding_id,
|
||||
"registered agent identity"
|
||||
);
|
||||
return Ok(stored_identity);
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("agent identity registration failed with status {status} from {url}: {body}")
|
||||
}
|
||||
|
||||
async fn mint_human_biscuit(
|
||||
&self,
|
||||
binding: &AgentIdentityBinding,
|
||||
target_method: &str,
|
||||
target_url: &str,
|
||||
) -> Result<String> {
|
||||
let url = agent_identity_biscuit_url(&self.chatgpt_base_url);
|
||||
let request_id = agent_identity_request_id()?;
|
||||
let client = create_client();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.bearer_auth(&binding.access_token)
|
||||
.header("X-Request-Id", request_id.clone())
|
||||
.header("X-Original-Method", target_method)
|
||||
.header("X-Original-Url", target_url)
|
||||
.timeout(AGENT_IDENTITY_BISCUIT_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("failed to send agent identity biscuit request to {url}"))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let human_biscuit = response
|
||||
.headers()
|
||||
.get("x-openai-authorization")
|
||||
.context("agent identity biscuit response did not include x-openai-authorization")?
|
||||
.to_str()
|
||||
.context("agent identity biscuit response header was not valid UTF-8")?
|
||||
.to_string();
|
||||
info!(
|
||||
request_id = %request_id,
|
||||
"minted human biscuit for agent identity registration"
|
||||
);
|
||||
return Ok(human_biscuit);
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"agent identity biscuit minting failed with status {status} from {url}: {body}"
|
||||
)
|
||||
}
|
||||
|
||||
fn load_stored_identity(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
binding: &AgentIdentityBinding,
|
||||
) -> Result<Option<StoredAgentIdentity>> {
|
||||
let Some(record) = auth.get_agent_identity(&binding.chatgpt_account_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let stored_identity =
|
||||
match StoredAgentIdentity::from_auth_record(binding, record, self.abom.clone()) {
|
||||
Ok(stored_identity) => stored_identity,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
binding_id = %binding.binding_id,
|
||||
error = %error,
|
||||
"stored agent identity is invalid; deleting cached value"
|
||||
);
|
||||
auth.remove_agent_identity()?;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
if !stored_identity.matches_binding(binding) {
|
||||
warn!(
|
||||
binding_id = %binding.binding_id,
|
||||
"stored agent identity binding no longer matches current auth; deleting cached value"
|
||||
);
|
||||
auth.remove_agent_identity()?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Err(error) = stored_identity.validate_key_material() {
|
||||
warn!(
|
||||
agent_runtime_id = %stored_identity.agent_runtime_id,
|
||||
binding_id = %binding.binding_id,
|
||||
error = %error,
|
||||
"stored agent identity key material is invalid; deleting cached value"
|
||||
);
|
||||
auth.remove_agent_identity()?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(stored_identity))
|
||||
}
|
||||
|
||||
fn store_identity(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
stored_identity: &StoredAgentIdentity,
|
||||
) -> Result<()> {
|
||||
auth.set_agent_identity(stored_identity.to_auth_record())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_tests(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
feature_enabled: bool,
|
||||
chatgpt_base_url: String,
|
||||
session_source: SessionSource,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
chatgpt_base_url,
|
||||
feature_enabled,
|
||||
abom: build_abom(session_source),
|
||||
ensure_lock: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StoredAgentIdentity {
|
||||
fn from_auth_record(
|
||||
binding: &AgentIdentityBinding,
|
||||
record: AgentIdentityAuthRecord,
|
||||
abom: AgentBillOfMaterials,
|
||||
) -> Result<Self> {
|
||||
if record.workspace_id != binding.chatgpt_account_id {
|
||||
anyhow::bail!(
|
||||
"stored agent identity workspace {:?} does not match current workspace {:?}",
|
||||
record.workspace_id,
|
||||
binding.chatgpt_account_id
|
||||
);
|
||||
}
|
||||
let signing_key = signing_key_from_private_key_pkcs8_base64(&record.agent_private_key)?;
|
||||
Ok(Self {
|
||||
binding_id: binding.binding_id.clone(),
|
||||
chatgpt_account_id: binding.chatgpt_account_id.clone(),
|
||||
chatgpt_user_id: record.chatgpt_user_id,
|
||||
agent_runtime_id: record.agent_runtime_id,
|
||||
private_key_pkcs8_base64: record.agent_private_key,
|
||||
public_key_ssh: encode_ssh_ed25519_public_key(&signing_key.verifying_key()),
|
||||
registered_at: record.registered_at,
|
||||
abom,
|
||||
})
|
||||
}
|
||||
|
||||
fn to_auth_record(&self) -> AgentIdentityAuthRecord {
|
||||
AgentIdentityAuthRecord {
|
||||
workspace_id: self.chatgpt_account_id.clone(),
|
||||
chatgpt_user_id: self.chatgpt_user_id.clone(),
|
||||
agent_runtime_id: self.agent_runtime_id.clone(),
|
||||
agent_private_key: self.private_key_pkcs8_base64.clone(),
|
||||
registered_at: self.registered_at.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_binding(&self, binding: &AgentIdentityBinding) -> bool {
|
||||
self.binding_id == binding.binding_id
|
||||
&& self.chatgpt_account_id == binding.chatgpt_account_id
|
||||
&& match binding.chatgpt_user_id.as_deref() {
|
||||
Some(chatgpt_user_id) => self.chatgpt_user_id.as_deref() == Some(chatgpt_user_id),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_key_material(&self) -> Result<()> {
|
||||
let signing_key = self.signing_key()?;
|
||||
let derived_public_key = encode_ssh_ed25519_public_key(&signing_key.verifying_key());
|
||||
anyhow::ensure!(
|
||||
self.public_key_ssh == derived_public_key,
|
||||
"stored public key does not match the private key"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn signing_key(&self) -> Result<SigningKey> {
|
||||
signing_key_from_private_key_pkcs8_base64(&self.private_key_pkcs8_base64)
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentIdentityBinding {
|
||||
fn from_auth(auth: &CodexAuth, forced_workspace_id: Option<String>) -> Option<Self> {
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let token_data = auth.get_token_data().ok()?;
|
||||
let resolved_account_id =
|
||||
forced_workspace_id
|
||||
.filter(|value| !value.is_empty())
|
||||
.or(token_data
|
||||
.account_id
|
||||
.clone()
|
||||
.filter(|value| !value.is_empty()))?;
|
||||
|
||||
Some(Self {
|
||||
binding_id: format!("chatgpt-account-{resolved_account_id}"),
|
||||
chatgpt_account_id: resolved_account_id,
|
||||
chatgpt_user_id: token_data
|
||||
.id_token
|
||||
.chatgpt_user_id
|
||||
.filter(|value| !value.is_empty()),
|
||||
access_token: token_data.access_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_abom(session_source: SessionSource) -> AgentBillOfMaterials {
|
||||
AgentBillOfMaterials {
|
||||
agent_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
agent_harness_id: match &session_source {
|
||||
SessionSource::VSCode => "codex-app".to_string(),
|
||||
SessionSource::Cli
|
||||
| SessionSource::Exec
|
||||
| SessionSource::Mcp
|
||||
| SessionSource::Custom(_)
|
||||
| SessionSource::SubAgent(_)
|
||||
| SessionSource::Unknown => "codex-cli".to_string(),
|
||||
},
|
||||
running_location: format!("{}-{}", session_source, std::env::consts::OS),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_agent_key_material() -> Result<GeneratedAgentKeyMaterial> {
|
||||
let mut secret_key_bytes = [0u8; 32];
|
||||
OsRng
|
||||
.try_fill_bytes(&mut secret_key_bytes)
|
||||
.context("failed to generate agent identity private key bytes")?;
|
||||
let signing_key = SigningKey::from_bytes(&secret_key_bytes);
|
||||
let private_key_pkcs8 = signing_key
|
||||
.to_pkcs8_der()
|
||||
.context("failed to encode agent identity private key as PKCS#8")?;
|
||||
|
||||
Ok(GeneratedAgentKeyMaterial {
|
||||
private_key_pkcs8_base64: BASE64_STANDARD.encode(private_key_pkcs8.as_bytes()),
|
||||
public_key_ssh: encode_ssh_ed25519_public_key(&signing_key.verifying_key()),
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_ssh_ed25519_public_key(verifying_key: &VerifyingKey) -> String {
|
||||
let mut blob = Vec::with_capacity(4 + 11 + 4 + 32);
|
||||
append_ssh_string(&mut blob, b"ssh-ed25519");
|
||||
append_ssh_string(&mut blob, verifying_key.as_bytes());
|
||||
format!("ssh-ed25519 {}", BASE64_STANDARD.encode(blob))
|
||||
}
|
||||
|
||||
fn append_ssh_string(buf: &mut Vec<u8>, value: &[u8]) {
|
||||
buf.extend_from_slice(&(value.len() as u32).to_be_bytes());
|
||||
buf.extend_from_slice(value);
|
||||
}
|
||||
|
||||
fn agent_registration_url(chatgpt_base_url: &str) -> String {
|
||||
let trimmed = chatgpt_base_url.trim_end_matches('/');
|
||||
format!("{trimmed}/v1/agent/register")
|
||||
}
|
||||
|
||||
fn signing_key_from_private_key_pkcs8_base64(private_key_pkcs8_base64: &str) -> Result<SigningKey> {
|
||||
let private_key = BASE64_STANDARD
|
||||
.decode(private_key_pkcs8_base64)
|
||||
.context("stored agent identity private key is not valid base64")?;
|
||||
SigningKey::from_pkcs8_der(&private_key)
|
||||
.context("stored agent identity private key is not valid PKCS#8")
|
||||
}
|
||||
|
||||
fn agent_identity_biscuit_url(chatgpt_base_url: &str) -> String {
|
||||
let trimmed = chatgpt_base_url.trim_end_matches('/');
|
||||
format!("{trimmed}/authenticate_app_v2")
|
||||
}
|
||||
|
||||
fn agent_identity_request_id() -> Result<String> {
|
||||
let mut request_id_bytes = [0u8; 16];
|
||||
OsRng
|
||||
.try_fill_bytes(&mut request_id_bytes)
|
||||
.context("failed to generate agent identity request id")?;
|
||||
Ok(format!(
|
||||
"codex-agent-identity-{}",
|
||||
URL_SAFE_NO_PAD.encode(request_id_bytes)
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use codex_app_server_protocol::AuthMode as ApiAuthMode;
|
||||
use codex_login::AuthCredentialsStoreMode;
|
||||
use codex_login::AuthDotJson;
|
||||
use codex_login::save_auth;
|
||||
use codex_login::token_data::IdTokenInfo;
|
||||
use codex_login::token_data::TokenData;
|
||||
use pretty_assertions::assert_eq;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_skips_when_feature_is_disabled() {
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(make_chatgpt_auth("account-123", Some("user-123")));
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ false,
|
||||
"https://chatgpt.com/backend-api/".to_string(),
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
assert_eq!(manager.ensure_registered_identity().await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_skips_for_api_key_auth() {
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test-key"));
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ true,
|
||||
"https://chatgpt.com/backend-api/".to_string(),
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
assert_eq!(manager.ensure_registered_identity().await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_registers_and_reuses_cached_identity() {
|
||||
let server = MockServer::start().await;
|
||||
let chatgpt_base_url = server.uri();
|
||||
mount_human_biscuit(&server, &chatgpt_base_url).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/agent/register"))
|
||||
.and(header("x-openai-authorization", "human-biscuit"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"agent_runtime_id": "agent_123",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(make_chatgpt_auth("account-123", Some("user-123")));
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ true,
|
||||
chatgpt_base_url,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
let first = manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("identity should be registered");
|
||||
let second = manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("identity should be reused");
|
||||
|
||||
assert_eq!(first.agent_runtime_id, "agent_123");
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.abom.agent_harness_id, "codex-cli");
|
||||
assert_eq!(first.chatgpt_account_id, "account-123");
|
||||
assert_eq!(first.chatgpt_user_id.as_deref(), Some("user-123"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_deletes_invalid_cached_identity_and_reregisters() {
|
||||
let server = MockServer::start().await;
|
||||
let chatgpt_base_url = server.uri();
|
||||
mount_human_biscuit(&server, &chatgpt_base_url).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/agent/register"))
|
||||
.and(header("x-openai-authorization", "human-biscuit"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"agent_runtime_id": "agent_456",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let auth = make_chatgpt_auth("account-123", Some("user-123"));
|
||||
let auth_manager = AuthManager::from_auth_for_testing(auth.clone());
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ true,
|
||||
chatgpt_base_url,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
let binding =
|
||||
AgentIdentityBinding::from_auth(&auth, /*forced_workspace_id*/ None).expect("binding");
|
||||
auth.set_agent_identity(AgentIdentityAuthRecord {
|
||||
workspace_id: "account-123".to_string(),
|
||||
chatgpt_user_id: Some("user-123".to_string()),
|
||||
agent_runtime_id: "agent_invalid".to_string(),
|
||||
agent_private_key: "not-valid-base64".to_string(),
|
||||
registered_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
})
|
||||
.expect("seed invalid identity");
|
||||
|
||||
let stored = manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("identity should be registered");
|
||||
|
||||
assert_eq!(stored.agent_runtime_id, "agent_456");
|
||||
let persisted = auth
|
||||
.get_agent_identity(&binding.chatgpt_account_id)
|
||||
.expect("stored identity");
|
||||
assert_eq!(persisted.agent_runtime_id, "agent_456");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_deletes_different_user_identity_and_reregisters() {
|
||||
let server = MockServer::start().await;
|
||||
let chatgpt_base_url = server.uri();
|
||||
mount_human_biscuit(&server, &chatgpt_base_url).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/agent/register"))
|
||||
.and(header("x-openai-authorization", "human-biscuit"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"agent_runtime_id": "agent_new",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let auth = make_chatgpt_auth("account-123", Some("user-new"));
|
||||
let stale_key = generate_agent_key_material().expect("key material");
|
||||
auth.set_agent_identity(AgentIdentityAuthRecord {
|
||||
workspace_id: "account-123".to_string(),
|
||||
chatgpt_user_id: Some("user-old".to_string()),
|
||||
agent_runtime_id: "agent_old".to_string(),
|
||||
agent_private_key: stale_key.private_key_pkcs8_base64,
|
||||
registered_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
})
|
||||
.expect("seed stale identity");
|
||||
|
||||
let auth_manager = AuthManager::from_auth_for_testing(auth.clone());
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ true,
|
||||
chatgpt_base_url,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
let stored = manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("identity should be registered");
|
||||
|
||||
assert_eq!(stored.agent_runtime_id, "agent_new");
|
||||
assert_eq!(stored.chatgpt_user_id.as_deref(), Some("user-new"));
|
||||
let persisted = auth
|
||||
.get_agent_identity("account-123")
|
||||
.expect("stored identity");
|
||||
assert_eq!(persisted.agent_runtime_id, "agent_new");
|
||||
assert_eq!(persisted.chatgpt_user_id.as_deref(), Some("user-new"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_uses_chatgpt_base_url() {
|
||||
let server = MockServer::start().await;
|
||||
let chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
mount_human_biscuit(&server, &chatgpt_base_url).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/v1/agent/register"))
|
||||
.and(header("x-openai-authorization", "human-biscuit"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"agent_runtime_id": "agent_canonical",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(make_chatgpt_auth("account-123", Some("user-123")));
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ true,
|
||||
chatgpt_base_url,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
let stored = manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("identity should be registered");
|
||||
assert_eq!(stored.agent_runtime_id, "agent_canonical");
|
||||
}
|
||||
|
||||
async fn mount_human_biscuit(server: &MockServer, chatgpt_base_url: &str) {
|
||||
let biscuit_url = agent_identity_biscuit_url(chatgpt_base_url);
|
||||
let biscuit_path = reqwest::Url::parse(&biscuit_url)
|
||||
.expect("biscuit URL parses")
|
||||
.path()
|
||||
.to_string();
|
||||
let target_url = agent_registration_url(chatgpt_base_url);
|
||||
Mock::given(method("GET"))
|
||||
.and(path(biscuit_path))
|
||||
.and(header("authorization", "Bearer access-token-account-123"))
|
||||
.and(header("x-original-method", "POST"))
|
||||
.and(header("x-original-url", target_url))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).insert_header("x-openai-authorization", "human-biscuit"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_ssh_ed25519_public_key_matches_expected_wire_shape() {
|
||||
let key_material = generate_agent_key_material().expect("key material");
|
||||
let (_, encoded_blob) = key_material
|
||||
.public_key_ssh
|
||||
.split_once(' ')
|
||||
.expect("public key contains scheme");
|
||||
let decoded = BASE64_STANDARD.decode(encoded_blob).expect("base64");
|
||||
|
||||
assert_eq!(&decoded[..4], 11u32.to_be_bytes().as_slice());
|
||||
assert_eq!(&decoded[4..15], b"ssh-ed25519");
|
||||
assert_eq!(&decoded[15..19], 32u32.to_be_bytes().as_slice());
|
||||
assert_eq!(decoded.len(), 51);
|
||||
}
|
||||
|
||||
fn make_chatgpt_auth(account_id: &str, user_id: Option<&str>) -> CodexAuth {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let auth_json = AuthDotJson {
|
||||
auth_mode: Some(ApiAuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: IdTokenInfo {
|
||||
email: None,
|
||||
chatgpt_plan_type: None,
|
||||
chatgpt_user_id: user_id.map(ToOwned::to_owned),
|
||||
chatgpt_account_id: Some(account_id.to_string()),
|
||||
raw_jwt: fake_id_token(account_id, user_id),
|
||||
},
|
||||
access_token: format!("access-token-{account_id}"),
|
||||
refresh_token: "refresh-token".to_string(),
|
||||
account_id: Some(account_id.to_string()),
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
};
|
||||
save_auth(tempdir.path(), &auth_json, AuthCredentialsStoreMode::File).expect("save auth");
|
||||
CodexAuth::from_auth_storage(tempdir.path(), AuthCredentialsStoreMode::File)
|
||||
.expect("load auth")
|
||||
.expect("auth")
|
||||
}
|
||||
|
||||
fn fake_id_token(account_id: &str, user_id: Option<&str>) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
||||
let payload = serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_user_id": user_id,
|
||||
"chatgpt_account_id": account_id,
|
||||
}
|
||||
});
|
||||
let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
|
||||
format!("{header}.{payload}.signature")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use crate::agent::Mailbox;
|
||||
use crate::agent::MailboxReceiver;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::agent_identity::AgentIdentityManager;
|
||||
use crate::apps::render_apps_section;
|
||||
use crate::commit_attribution::commit_message_trailer_instruction;
|
||||
use crate::compact;
|
||||
@@ -92,6 +93,7 @@ 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::ToolName;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
@@ -1511,6 +1513,56 @@ impl Session {
|
||||
});
|
||||
}
|
||||
|
||||
fn start_agent_identity_registration(self: &Arc<Self>) {
|
||||
if !self.services.agent_identity_manager.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let weak_sess = Arc::downgrade(self);
|
||||
let mut auth_state_rx = self.services.auth_manager.subscribe_auth_state();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Some(sess) = weak_sess.upgrade() else {
|
||||
return;
|
||||
};
|
||||
match sess
|
||||
.services
|
||||
.agent_identity_manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => return,
|
||||
Ok(None) => {
|
||||
drop(sess);
|
||||
if auth_state_rx.changed().await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
sess.fail_agent_identity_registration(error).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn fail_agent_identity_registration(self: &Arc<Self>, error: anyhow::Error) {
|
||||
warn!(error = %error, "agent identity registration failed");
|
||||
let message = format!(
|
||||
"Agent identity registration failed. Codex cannot continue while `features.use_agent_identity` is enabled: {error}"
|
||||
);
|
||||
self.send_event_raw(Event {
|
||||
id: self.next_internal_sub_id(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message,
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
handlers::shutdown(self, self.next_internal_sub_id()).await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_turn_context(
|
||||
conversation_id: ThreadId,
|
||||
@@ -2057,6 +2109,11 @@ impl Session {
|
||||
hooks,
|
||||
rollout: Mutex::new(rollout_recorder),
|
||||
user_shell: Arc::new(default_shell),
|
||||
agent_identity_manager: Arc::new(AgentIdentityManager::new(
|
||||
config.as_ref(),
|
||||
Arc::clone(&auth_manager),
|
||||
session_configuration.session_source.clone(),
|
||||
)),
|
||||
shell_snapshot_tx,
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
exec_policy,
|
||||
@@ -2154,6 +2211,7 @@ impl Session {
|
||||
|
||||
// Start the watcher after SessionConfigured so it cannot emit earlier events.
|
||||
sess.start_skills_watcher_listener();
|
||||
sess.start_agent_identity_registration();
|
||||
// Construct sandbox_state before MCP startup so it can be sent to each
|
||||
// MCP server immediately after it becomes ready (avoiding blocking).
|
||||
let sandbox_state = SandboxState {
|
||||
@@ -4456,16 +4514,12 @@ impl Session {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_mcp_tool_info(
|
||||
&self,
|
||||
name: &str,
|
||||
namespace: Option<&str>,
|
||||
) -> Option<ToolInfo> {
|
||||
pub(crate) async fn resolve_mcp_tool_info(&self, tool_name: &ToolName) -> Option<ToolInfo> {
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.resolve_tool_info(name, namespace)
|
||||
.resolve_tool_info(tool_name)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
|
||||
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
|
||||
let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs {
|
||||
config,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
@@ -97,7 +97,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)),
|
||||
parent_trace: None,
|
||||
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
|
||||
})
|
||||
}))
|
||||
.await?;
|
||||
if parent_session.enabled(codex_features::Feature::GeneralAnalytics) {
|
||||
let thread_config = codex.thread_config_snapshot().await;
|
||||
@@ -172,7 +172,7 @@ pub(crate) async fn run_codex_thread_one_shot(
|
||||
// Use a child token so we can stop the delegate after completion without
|
||||
// requiring the caller to cancel the parent token.
|
||||
let child_cancel = cancel_token.child_token();
|
||||
let io = run_codex_thread_interactive(
|
||||
let io = Box::pin(run_codex_thread_interactive(
|
||||
config,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
@@ -181,7 +181,7 @@ pub(crate) async fn run_codex_thread_one_shot(
|
||||
child_cancel.clone(),
|
||||
subagent_source,
|
||||
initial_history,
|
||||
)
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Send the initial input to kick off the one-shot turn.
|
||||
|
||||
@@ -110,6 +110,7 @@ use opentelemetry::trace::TraceId;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::timeout;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
use codex_protocol::mcp::CallToolResult as McpCallToolResult;
|
||||
@@ -904,7 +905,7 @@ fn mcp_tool_exposure_searches_large_effective_tool_sets() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_exposure_directly_exposes_explicit_apps_in_large_search_sets() {
|
||||
fn mcp_tool_exposure_directly_exposes_explicit_apps_without_deferred_overlap() {
|
||||
let config = test_config();
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true);
|
||||
let mut mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
|
||||
@@ -935,13 +936,19 @@ fn mcp_tool_exposure_directly_exposes_explicit_apps_in_large_search_sets() {
|
||||
);
|
||||
assert_eq!(
|
||||
exposure.deferred_tools.as_ref().map(HashMap::len),
|
||||
Some(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD)
|
||||
Some(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1)
|
||||
);
|
||||
let deferred_tools = exposure
|
||||
.deferred_tools
|
||||
.as_ref()
|
||||
.expect("large tool sets should be discoverable through tool_search");
|
||||
assert!(deferred_tools.contains_key("mcp__codex_apps__calendar_create_event"));
|
||||
assert!(
|
||||
tool_names
|
||||
.iter()
|
||||
.all(|direct_tool_name| !deferred_tools.contains_key(direct_tool_name)),
|
||||
"direct tools should not also be deferred: {tool_names:?}"
|
||||
);
|
||||
assert!(!deferred_tools.contains_key("mcp__codex_apps__calendar_create_event"));
|
||||
assert!(deferred_tools.contains_key("mcp__rmcp__tool_0"));
|
||||
}
|
||||
|
||||
@@ -2808,6 +2815,11 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
}),
|
||||
rollout: Mutex::new(None),
|
||||
user_shell: Arc::new(default_user_shell()),
|
||||
agent_identity_manager: Arc::new(crate::agent_identity::AgentIdentityManager::new(
|
||||
config.as_ref(),
|
||||
Arc::clone(&auth_manager),
|
||||
session_configuration.session_source.clone(),
|
||||
)),
|
||||
shell_snapshot_tx: watch::channel(None).0,
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
exec_policy,
|
||||
@@ -3660,6 +3672,11 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
}),
|
||||
rollout: Mutex::new(None),
|
||||
user_shell: Arc::new(default_user_shell()),
|
||||
agent_identity_manager: Arc::new(crate::agent_identity::AgentIdentityManager::new(
|
||||
config.as_ref(),
|
||||
Arc::clone(&auth_manager),
|
||||
session_configuration.session_source.clone(),
|
||||
)),
|
||||
shell_snapshot_tx: watch::channel(None).0,
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
exec_policy,
|
||||
@@ -3764,6 +3781,42 @@ pub(crate) async fn make_session_and_context_with_rx() -> (
|
||||
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fail_agent_identity_registration_emits_error_and_shutdown() {
|
||||
let (session, _turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
|
||||
session
|
||||
.fail_agent_identity_registration(anyhow::anyhow!("registration exploded"))
|
||||
.await;
|
||||
|
||||
let error_event = timeout(Duration::from_secs(1), rx_event.recv())
|
||||
.await
|
||||
.expect("error event should arrive")
|
||||
.expect("error event should be readable");
|
||||
match error_event.msg {
|
||||
EventMsg::Error(ErrorEvent {
|
||||
message,
|
||||
codex_error_info,
|
||||
}) => {
|
||||
assert_eq!(
|
||||
message,
|
||||
"Agent identity registration failed. Codex cannot continue while `features.use_agent_identity` is enabled: registration exploded".to_string()
|
||||
);
|
||||
assert_eq!(codex_error_info, Some(CodexErrorInfo::Other));
|
||||
}
|
||||
other => panic!("expected error event, got {other:?}"),
|
||||
}
|
||||
|
||||
let shutdown_event = timeout(Duration::from_secs(1), rx_event.recv())
|
||||
.await
|
||||
.expect("shutdown event should arrive")
|
||||
.expect("shutdown event should be readable");
|
||||
match shutdown_event.msg {
|
||||
EventMsg::ShutdownComplete => {}
|
||||
other => panic!("expected shutdown event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_mcp_servers_is_deferred_until_next_turn() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
+63
-36
@@ -251,8 +251,24 @@ pub fn build_exec_request(
|
||||
codex_linux_sandbox_exe: &Option<PathBuf>,
|
||||
use_legacy_landlock: bool,
|
||||
) -> Result<ExecRequest> {
|
||||
let windows_sandbox_level = params.windows_sandbox_level;
|
||||
let enforce_managed_network = params.network.is_some();
|
||||
let ExecParams {
|
||||
command,
|
||||
cwd,
|
||||
mut env,
|
||||
expiration,
|
||||
capture_policy,
|
||||
network,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
|
||||
// TODO: Should arg0 be set on the ExecRequest that is returned?
|
||||
arg0: _,
|
||||
// These fields are related to approvals, so can be ignored here.
|
||||
justification: _,
|
||||
sandbox_permissions: _,
|
||||
} = params;
|
||||
|
||||
let enforce_managed_network = network.is_some();
|
||||
let sandbox_type = select_process_exec_tool_sandbox_type(
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
@@ -261,19 +277,6 @@ pub fn build_exec_request(
|
||||
);
|
||||
tracing::debug!("Sandbox type: {sandbox_type:?}");
|
||||
|
||||
let ExecParams {
|
||||
command,
|
||||
cwd,
|
||||
mut env,
|
||||
expiration,
|
||||
capture_policy,
|
||||
network,
|
||||
sandbox_permissions: _,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
justification: _,
|
||||
arg0: _,
|
||||
} = params;
|
||||
if let Some(network) = network.as_ref() {
|
||||
network.apply_to_env(&mut env);
|
||||
}
|
||||
@@ -357,7 +360,8 @@ pub(crate) async fn execute_exec_request(
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
// TODO(mbolin): Use file_system_sandbox_policy instead of sandbox_policy.
|
||||
file_system_sandbox_policy: _,
|
||||
network_sandbox_policy,
|
||||
windows_sandbox_filesystem_overrides,
|
||||
arg0,
|
||||
@@ -378,21 +382,40 @@ pub(crate) async fn execute_exec_request(
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
let raw_output_result = exec(
|
||||
let raw_output_result = get_raw_output_result(
|
||||
params,
|
||||
sandbox,
|
||||
&sandbox_policy,
|
||||
&file_system_sandbox_policy,
|
||||
windows_sandbox_filesystem_overrides.as_ref(),
|
||||
network_sandbox_policy,
|
||||
stdout_stream,
|
||||
after_spawn,
|
||||
sandbox,
|
||||
&sandbox_policy,
|
||||
windows_sandbox_filesystem_overrides.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let duration = start.elapsed();
|
||||
finalize_exec_result(raw_output_result, sandbox, duration)
|
||||
}
|
||||
|
||||
async fn get_raw_output_result(
|
||||
params: ExecParams,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
stdout_stream: Option<StdoutStream>,
|
||||
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))] 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;
|
||||
}
|
||||
|
||||
exec(params, network_sandbox_policy, stdout_stream, after_spawn).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn extract_create_process_as_user_error_code(err: &str) -> Option<String> {
|
||||
let marker = "CreateProcessAsUserW failed: ";
|
||||
@@ -799,26 +822,24 @@ fn aggregate_output(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// This is a general-purpose function for executing a command specified by
|
||||
/// [ExecParams]. Events are reported via `stdout_stream`, if specified, and
|
||||
/// `after_spawn` is invoked once the child process has been spawned, before
|
||||
/// output consumption begins.
|
||||
///
|
||||
/// `network_sandbox_policy` is used to determine whether
|
||||
/// CODEX_SANDBOX_NETWORK_DISABLED=1 is added to the environment of the spawned
|
||||
/// process.
|
||||
///
|
||||
/// Note this command does not apply any sandboxing logic. The caller is
|
||||
/// responsible for constructing [ExecParams::command] to include any sandboxing
|
||||
/// wrapper args, as appropriate.
|
||||
async fn exec(
|
||||
params: ExecParams,
|
||||
_sandbox: SandboxType,
|
||||
_sandbox_policy: &SandboxPolicy,
|
||||
_file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
_windows_sandbox_filesystem_overrides: Option<&WindowsSandboxFilesystemOverrides>,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
stdout_stream: Option<StdoutStream>,
|
||||
after_spawn: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<RawExecToolCallOutput> {
|
||||
#[cfg(target_os = "windows")]
|
||||
if _sandbox == SandboxType::WindowsRestrictedToken {
|
||||
return exec_windows_sandbox(
|
||||
params,
|
||||
_sandbox_policy,
|
||||
_windows_sandbox_filesystem_overrides,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let ExecParams {
|
||||
command,
|
||||
cwd,
|
||||
@@ -827,8 +848,14 @@ async fn exec(
|
||||
arg0,
|
||||
expiration,
|
||||
capture_policy,
|
||||
|
||||
// If applicable, these fields should have been honored upstream of
|
||||
// this exec call.
|
||||
windows_sandbox_level: _,
|
||||
..
|
||||
windows_sandbox_private_desktop: _,
|
||||
// These fields are related to approvals, so can be ignored here.
|
||||
sandbox_permissions: _,
|
||||
justification: _,
|
||||
} = params;
|
||||
if let Some(network) = network.as_ref() {
|
||||
network.apply_to_env(&mut env);
|
||||
|
||||
@@ -277,10 +277,6 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> {
|
||||
justification: None,
|
||||
arg0: None,
|
||||
},
|
||||
SandboxType::None,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
&FileSystemSandboxPolicy::unrestricted(),
|
||||
/*windows_sandbox_filesystem_overrides*/ None,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*stdout_stream*/ None,
|
||||
/*after_spawn*/ None,
|
||||
@@ -317,10 +313,6 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p
|
||||
justification: None,
|
||||
arg0: None,
|
||||
},
|
||||
SandboxType::None,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
&FileSystemSandboxPolicy::unrestricted(),
|
||||
/*windows_sandbox_filesystem_overrides*/ None,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*stdout_stream*/ None,
|
||||
/*after_spawn*/ None,
|
||||
@@ -931,10 +923,6 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()>
|
||||
|
||||
let output = exec(
|
||||
params,
|
||||
SandboxType::None,
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
&FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()),
|
||||
/*windows_sandbox_filesystem_overrides*/ None,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*stdout_stream*/ None,
|
||||
/*after_spawn*/ None,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use crate::plugins::MarketplaceAddRequest;
|
||||
use crate::plugins::PluginId;
|
||||
use crate::plugins::PluginInstallRequest;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::plugins::add_marketplace;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
@@ -9,6 +15,8 @@ use toml::Value as TomlValue;
|
||||
|
||||
const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.detect";
|
||||
const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import";
|
||||
// Installed marketplace roots always expose their manifest at this relative path.
|
||||
const INSTALLED_MARKETPLACE_MANIFEST_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExternalAgentConfigDetectOptions {
|
||||
@@ -21,36 +29,57 @@ pub enum ExternalAgentConfigMigrationItemType {
|
||||
Config,
|
||||
Skills,
|
||||
AgentsMd,
|
||||
Plugins,
|
||||
McpServerConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginsMigration {
|
||||
pub marketplace_name: String,
|
||||
pub plugin_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MigrationDetails {
|
||||
pub plugins: Vec<PluginsMigration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
struct PluginImportOutcome {
|
||||
succeeded_marketplaces: Vec<String>,
|
||||
succeeded_plugin_ids: Vec<String>,
|
||||
failed_marketplaces: Vec<String>,
|
||||
failed_plugin_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExternalAgentConfigMigrationItem {
|
||||
pub item_type: ExternalAgentConfigMigrationItemType,
|
||||
pub description: String,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub details: Option<MigrationDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExternalAgentConfigService {
|
||||
codex_home: PathBuf,
|
||||
claude_home: PathBuf,
|
||||
external_agent_home: PathBuf,
|
||||
}
|
||||
|
||||
impl ExternalAgentConfigService {
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
let claude_home = default_claude_home();
|
||||
let external_agent_home = default_external_agent_home();
|
||||
Self {
|
||||
codex_home,
|
||||
claude_home,
|
||||
external_agent_home,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_test(codex_home: PathBuf, claude_home: PathBuf) -> Self {
|
||||
fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self {
|
||||
Self {
|
||||
codex_home,
|
||||
claude_home,
|
||||
external_agent_home,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +102,10 @@ impl ExternalAgentConfigService {
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub fn import(&self, migration_items: Vec<ExternalAgentConfigMigrationItem>) -> io::Result<()> {
|
||||
pub async fn import(
|
||||
&self,
|
||||
migration_items: Vec<ExternalAgentConfigMigrationItem>,
|
||||
) -> io::Result<()> {
|
||||
for migration_item in migration_items {
|
||||
match migration_item.item_type {
|
||||
ExternalAgentConfigMigrationItemType::Config => {
|
||||
@@ -100,6 +132,35 @@ impl ExternalAgentConfigService {
|
||||
/*skills_count*/ None,
|
||||
);
|
||||
}
|
||||
ExternalAgentConfigMigrationItemType::Plugins => {
|
||||
let service = self.clone();
|
||||
let cwd = migration_item.cwd;
|
||||
let details = migration_item.details;
|
||||
tokio::spawn(async move {
|
||||
match service.import_plugins(cwd.as_deref(), details).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
succeeded_marketplaces = outcome.succeeded_marketplaces.len(),
|
||||
succeeded_plugin_ids = outcome.succeeded_plugin_ids.len(),
|
||||
failed_marketplaces = outcome.failed_marketplaces.len(),
|
||||
failed_plugin_ids = outcome.failed_plugin_ids.len(),
|
||||
"external agent config plugin import completed"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"external agent config plugin import failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::Plugins,
|
||||
/*skills_count*/ None,
|
||||
);
|
||||
}
|
||||
ExternalAgentConfigMigrationItemType::McpServerConfig => {}
|
||||
}
|
||||
}
|
||||
@@ -114,18 +175,16 @@ impl ExternalAgentConfigService {
|
||||
) -> io::Result<()> {
|
||||
let cwd = repo_root.map(Path::to_path_buf);
|
||||
let source_settings = repo_root.map_or_else(
|
||||
|| self.claude_home.join("settings.json"),
|
||||
|| self.external_agent_home.join("settings.json"),
|
||||
|repo_root| repo_root.join(".claude").join("settings.json"),
|
||||
);
|
||||
let settings = read_external_settings(&source_settings)?;
|
||||
let target_config = repo_root.map_or_else(
|
||||
|| self.codex_home.join("config.toml"),
|
||||
|repo_root| repo_root.join(".codex").join("config.toml"),
|
||||
);
|
||||
if source_settings.is_file() {
|
||||
let raw_settings = fs::read_to_string(&source_settings)?;
|
||||
let settings: JsonValue = serde_json::from_str(&raw_settings)
|
||||
.map_err(|err| invalid_data_error(err.to_string()))?;
|
||||
let migrated = build_config_from_external(&settings)?;
|
||||
if let Some(settings) = settings.as_ref() {
|
||||
let migrated = build_config_from_external(settings)?;
|
||||
if !is_empty_toml_table(&migrated) {
|
||||
let mut should_include = true;
|
||||
if target_config.exists() {
|
||||
@@ -149,6 +208,7 @@ impl ExternalAgentConfigService {
|
||||
target_config.display()
|
||||
),
|
||||
cwd: cwd.clone(),
|
||||
details: None,
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
@@ -159,8 +219,15 @@ impl ExternalAgentConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
self.detect_plugin_migration(
|
||||
source_settings.as_path(),
|
||||
cwd.clone(),
|
||||
settings.as_ref(),
|
||||
items,
|
||||
);
|
||||
|
||||
let source_skills = repo_root.map_or_else(
|
||||
|| self.claude_home.join("skills"),
|
||||
|| self.external_agent_home.join("skills"),
|
||||
|repo_root| repo_root.join(".claude").join("skills"),
|
||||
);
|
||||
let target_skills = repo_root.map_or_else(
|
||||
@@ -177,6 +244,7 @@ impl ExternalAgentConfigService {
|
||||
target_skills.display()
|
||||
),
|
||||
cwd: cwd.clone(),
|
||||
details: None,
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
@@ -188,7 +256,7 @@ impl ExternalAgentConfigService {
|
||||
let source_agents_md = if let Some(repo_root) = repo_root {
|
||||
find_repo_agents_md_source(repo_root)?
|
||||
} else {
|
||||
let path = self.claude_home.join("CLAUDE.md");
|
||||
let path = self.external_agent_home.join("CLAUDE.md");
|
||||
is_non_empty_text_file(&path)?.then_some(path)
|
||||
};
|
||||
let target_agents_md = repo_root.map_or_else(
|
||||
@@ -206,6 +274,7 @@ impl ExternalAgentConfigService {
|
||||
target_agents_md.display()
|
||||
),
|
||||
cwd,
|
||||
details: None,
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
@@ -224,6 +293,98 @@ impl ExternalAgentConfigService {
|
||||
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"))
|
||||
}
|
||||
|
||||
fn detect_plugin_migration(
|
||||
&self,
|
||||
source_settings: &Path,
|
||||
cwd: Option<PathBuf>,
|
||||
settings: Option<&JsonValue>,
|
||||
items: &mut Vec<ExternalAgentConfigMigrationItem>,
|
||||
) {
|
||||
let Some(plugin_details) = settings.and_then(extract_plugin_migration_details) else {
|
||||
return;
|
||||
};
|
||||
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: format!("Import enabled plugins from {}", source_settings.display()),
|
||||
cwd,
|
||||
details: Some(plugin_details),
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::Plugins,
|
||||
/*skills_count*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
async fn import_plugins(
|
||||
&self,
|
||||
cwd: Option<&Path>,
|
||||
details: Option<MigrationDetails>,
|
||||
) -> io::Result<PluginImportOutcome> {
|
||||
let Some(MigrationDetails { plugins }) = details else {
|
||||
return Err(invalid_data_error(
|
||||
"plugins migration item is missing details".to_string(),
|
||||
));
|
||||
};
|
||||
let mut outcome = PluginImportOutcome::default();
|
||||
let plugins_manager = PluginsManager::new(self.codex_home.clone());
|
||||
for plugin_group in plugins {
|
||||
let marketplace_name = plugin_group.marketplace_name.clone();
|
||||
let plugin_names = plugin_group.plugin_names;
|
||||
let plugin_ids = plugin_names
|
||||
.iter()
|
||||
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
|
||||
.collect::<Vec<_>>();
|
||||
let import_source =
|
||||
read_marketplace_import_source(cwd, &self.external_agent_home, &marketplace_name)?;
|
||||
let Some(import_source) = import_source else {
|
||||
outcome.failed_marketplaces.push(marketplace_name);
|
||||
outcome.failed_plugin_ids.extend(plugin_ids);
|
||||
continue;
|
||||
};
|
||||
let request = MarketplaceAddRequest {
|
||||
source: import_source.source,
|
||||
ref_name: import_source.ref_name,
|
||||
sparse_paths: Vec::new(),
|
||||
};
|
||||
let add_marketplace_outcome = add_marketplace(self.codex_home.clone(), request).await;
|
||||
let marketplace_path = match add_marketplace_outcome {
|
||||
Ok(add_marketplace_outcome) => {
|
||||
outcome
|
||||
.succeeded_marketplaces
|
||||
.push(marketplace_name.clone());
|
||||
add_marketplace_outcome
|
||||
.installed_root
|
||||
.join(INSTALLED_MARKETPLACE_MANIFEST_RELATIVE_PATH)
|
||||
}
|
||||
Err(_) => {
|
||||
outcome.failed_marketplaces.push(marketplace_name);
|
||||
outcome.failed_plugin_ids.extend(plugin_ids);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for plugin_name in plugin_names {
|
||||
match plugins_manager
|
||||
.install_plugin(PluginInstallRequest {
|
||||
plugin_name: plugin_name.clone(),
|
||||
marketplace_path: marketplace_path.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => outcome
|
||||
.succeeded_plugin_ids
|
||||
.push(format!("{plugin_name}@{marketplace_name}")),
|
||||
Err(_) => outcome
|
||||
.failed_plugin_ids
|
||||
.push(format!("{plugin_name}@{marketplace_name}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn import_config(&self, cwd: Option<&Path>) -> io::Result<()> {
|
||||
let (source_settings, target_config) = if let Some(repo_root) = find_repo_root(cwd)? {
|
||||
(
|
||||
@@ -234,7 +395,7 @@ impl ExternalAgentConfigService {
|
||||
return Ok(());
|
||||
} else {
|
||||
(
|
||||
self.claude_home.join("settings.json"),
|
||||
self.external_agent_home.join("settings.json"),
|
||||
self.codex_home.join("config.toml"),
|
||||
)
|
||||
};
|
||||
@@ -286,7 +447,7 @@ impl ExternalAgentConfigService {
|
||||
return Ok(0);
|
||||
} else {
|
||||
(
|
||||
self.claude_home.join("skills"),
|
||||
self.external_agent_home.join("skills"),
|
||||
self.home_target_skills_dir(),
|
||||
)
|
||||
};
|
||||
@@ -326,7 +487,7 @@ impl ExternalAgentConfigService {
|
||||
return Ok(());
|
||||
} else {
|
||||
(
|
||||
self.claude_home.join("CLAUDE.md"),
|
||||
self.external_agent_home.join("CLAUDE.md"),
|
||||
self.codex_home.join("AGENTS.md"),
|
||||
)
|
||||
};
|
||||
@@ -345,7 +506,7 @@ impl ExternalAgentConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_claude_home() -> PathBuf {
|
||||
fn default_external_agent_home() -> PathBuf {
|
||||
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
|
||||
return PathBuf::from(home).join(".claude");
|
||||
}
|
||||
@@ -353,6 +514,139 @@ fn default_claude_home() -> PathBuf {
|
||||
PathBuf::from(".claude")
|
||||
}
|
||||
|
||||
fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let raw_settings = fs::read_to_string(path)?;
|
||||
let settings =
|
||||
serde_json::from_str(&raw_settings).map_err(|err| invalid_data_error(err.to_string()))?;
|
||||
Ok(Some(settings))
|
||||
}
|
||||
|
||||
fn extract_plugin_migration_details(settings: &JsonValue) -> Option<MigrationDetails> {
|
||||
let mut plugins = BTreeMap::new();
|
||||
for plugin_id in collect_enabled_plugins(settings) {
|
||||
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
|
||||
continue;
|
||||
};
|
||||
let plugin_group = plugins
|
||||
.entry(plugin_id.marketplace_name.clone())
|
||||
.or_insert_with(|| PluginsMigration {
|
||||
marketplace_name: plugin_id.marketplace_name.clone(),
|
||||
plugin_names: Vec::new(),
|
||||
});
|
||||
plugin_group.plugin_names.push(plugin_id.plugin_name);
|
||||
}
|
||||
|
||||
let plugins = plugins
|
||||
.into_values()
|
||||
.filter_map(|mut plugin_group| {
|
||||
if plugin_group.plugin_names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
plugin_group.plugin_names.sort();
|
||||
Some(plugin_group)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if plugins.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(MigrationDetails { plugins })
|
||||
}
|
||||
|
||||
fn collect_enabled_plugins(settings: &JsonValue) -> Vec<String> {
|
||||
let Some(enabled_plugins) = settings
|
||||
.as_object()
|
||||
.and_then(|settings| settings.get("enabledPlugins"))
|
||||
.and_then(JsonValue::as_object)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
enabled_plugins
|
||||
.iter()
|
||||
.filter_map(|(plugin_key, enabled)| {
|
||||
if !enabled.as_bool().unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
PluginId::parse(plugin_key)
|
||||
.ok()
|
||||
.map(|plugin_id| plugin_id.as_key())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_marketplace_import_sources(
|
||||
settings: &JsonValue,
|
||||
) -> BTreeMap<String, MarketplaceImportSource> {
|
||||
let Some(extra_known_marketplaces) = settings
|
||||
.as_object()
|
||||
.and_then(|settings| settings.get("extraKnownMarketplaces"))
|
||||
.and_then(JsonValue::as_object)
|
||||
else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
|
||||
extra_known_marketplaces
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
let source_fields = if let Some(source) = value.get("source")
|
||||
&& source.is_object()
|
||||
{
|
||||
source.as_object()?
|
||||
} else {
|
||||
value.as_object()?
|
||||
};
|
||||
let source = source_fields
|
||||
.get("repo")
|
||||
.or_else(|| source_fields.get("url"))
|
||||
.or_else(|| source_fields.get("path"))
|
||||
.or_else(|| value.get("source"))?
|
||||
.as_str()?
|
||||
.trim()
|
||||
.to_string();
|
||||
if source.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ref_name = source_fields
|
||||
.get("ref")
|
||||
.or_else(|| value.get("ref"))
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
Some((name.clone(), MarketplaceImportSource { source, ref_name }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct MarketplaceImportSource {
|
||||
source: String,
|
||||
ref_name: Option<String>,
|
||||
}
|
||||
|
||||
fn read_marketplace_import_source(
|
||||
cwd: Option<&Path>,
|
||||
external_agent_home: &Path,
|
||||
marketplace_name: &str,
|
||||
) -> io::Result<Option<MarketplaceImportSource>> {
|
||||
let source_settings = cwd.map_or_else(
|
||||
|| external_agent_home.join("settings.json"),
|
||||
|cwd| cwd.join(".claude").join("settings.json"),
|
||||
);
|
||||
let Some(settings) = read_external_settings(&source_settings)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(collect_marketplace_import_sources(&settings).remove(marketplace_name))
|
||||
}
|
||||
|
||||
fn find_repo_root(cwd: Option<&Path>) -> io::Result<Option<PathBuf>> {
|
||||
let Some(cwd) = cwd.filter(|cwd| !cwd.as_os_str().is_empty()) else {
|
||||
return Ok(None);
|
||||
@@ -480,11 +774,11 @@ fn is_skill_md(path: &Path) -> bool {
|
||||
|
||||
fn rewrite_and_copy_text_file(source: &Path, target: &Path) -> io::Result<()> {
|
||||
let source_contents = fs::read_to_string(source)?;
|
||||
let rewritten = rewrite_claude_terms(&source_contents);
|
||||
let rewritten = rewrite_external_agent_terms(&source_contents);
|
||||
fs::write(target, rewritten)
|
||||
}
|
||||
|
||||
fn rewrite_claude_terms(content: &str) -> String {
|
||||
fn rewrite_external_agent_terms(content: &str) -> String {
|
||||
let mut rewritten = replace_case_insensitive_with_boundaries(content, "claude.md", "AGENTS.md");
|
||||
for from in [
|
||||
"claude code",
|
||||
@@ -662,6 +956,7 @@ fn migration_metric_tags(
|
||||
ExternalAgentConfigMigrationItemType::Config => "config",
|
||||
ExternalAgentConfigMigrationItemType::Skills => "skills",
|
||||
ExternalAgentConfigMigrationItemType::AgentsMd => "agents_md",
|
||||
ExternalAgentConfigMigrationItemType::Plugins => "plugins",
|
||||
ExternalAgentConfigMigrationItemType::McpServerConfig => "mcp_server_config",
|
||||
};
|
||||
let mut tags = vec![("migration_type", migration_type.to_string())];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fixture_paths() -> (TempDir, PathBuf, PathBuf) {
|
||||
@@ -13,6 +14,15 @@ fn service_for_paths(claude_home: PathBuf, codex_home: PathBuf) -> ExternalAgent
|
||||
ExternalAgentConfigService::new_for_test(codex_home, claude_home)
|
||||
}
|
||||
|
||||
fn github_plugin_details() -> MigrationDetails {
|
||||
MigrationDetails {
|
||||
plugins: vec![PluginsMigration {
|
||||
marketplace_name: "acme-tools".to_string(),
|
||||
plugin_names: vec!["formatter".to_string()],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_home_lists_config_skills_and_agents_md() {
|
||||
let (_root, claude_home, codex_home) = fixture_paths();
|
||||
@@ -44,6 +54,7 @@ fn detect_home_lists_config_skills_and_agents_md() {
|
||||
codex_home.join("config.toml").display()
|
||||
),
|
||||
cwd: None,
|
||||
details: None,
|
||||
},
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Skills,
|
||||
@@ -53,6 +64,7 @@ fn detect_home_lists_config_skills_and_agents_md() {
|
||||
agents_skills.display()
|
||||
),
|
||||
cwd: None,
|
||||
details: None,
|
||||
},
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
@@ -62,6 +74,7 @@ fn detect_home_lists_config_skills_and_agents_md() {
|
||||
codex_home.join("AGENTS.md").display()
|
||||
),
|
||||
cwd: None,
|
||||
details: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -93,6 +106,7 @@ fn detect_repo_lists_agents_md_for_each_cwd() {
|
||||
repo_root.join("AGENTS.md").display(),
|
||||
),
|
||||
cwd: Some(repo_root.clone()),
|
||||
details: None,
|
||||
},
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
@@ -102,14 +116,15 @@ fn detect_repo_lists_agents_md_for_each_cwd() {
|
||||
repo_root.join("AGENTS.md").display(),
|
||||
),
|
||||
cwd: Some(repo_root),
|
||||
details: None,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(items, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
|
||||
#[tokio::test]
|
||||
async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
|
||||
let (_root, claude_home, codex_home) = fixture_paths();
|
||||
let agents_skills = codex_home
|
||||
.parent()
|
||||
@@ -134,18 +149,22 @@ fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: String::new(),
|
||||
cwd: None,
|
||||
details: None,
|
||||
},
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Config,
|
||||
description: String::new(),
|
||||
cwd: None,
|
||||
details: None,
|
||||
},
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Skills,
|
||||
description: String::new(),
|
||||
cwd: None,
|
||||
details: None,
|
||||
},
|
||||
])
|
||||
.await
|
||||
.expect("import");
|
||||
|
||||
assert_eq!(
|
||||
@@ -164,8 +183,8 @@ fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_home_skips_empty_config_migration() {
|
||||
#[tokio::test]
|
||||
async fn import_home_skips_empty_config_migration() {
|
||||
let (_root, claude_home, codex_home) = fixture_paths();
|
||||
fs::create_dir_all(&claude_home).expect("create claude home");
|
||||
fs::write(
|
||||
@@ -179,7 +198,9 @@ fn import_home_skips_empty_config_migration() {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Config,
|
||||
description: String::new(),
|
||||
cwd: None,
|
||||
details: None,
|
||||
}])
|
||||
.await
|
||||
.expect("import");
|
||||
|
||||
assert!(!codex_home.join("config.toml").exists());
|
||||
@@ -239,8 +260,8 @@ fn detect_home_skips_skills_when_all_skill_directories_exist() {
|
||||
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
|
||||
#[tokio::test]
|
||||
async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
|
||||
let root = TempDir::new().expect("create tempdir");
|
||||
let repo_root = root.path().join("repo-a");
|
||||
let repo_with_existing_target = root.path().join("repo-b");
|
||||
@@ -264,13 +285,16 @@ fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: String::new(),
|
||||
cwd: Some(repo_root.clone()),
|
||||
details: None,
|
||||
},
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: String::new(),
|
||||
cwd: Some(repo_with_existing_target.clone()),
|
||||
details: None,
|
||||
},
|
||||
])
|
||||
.await
|
||||
.expect("import");
|
||||
|
||||
assert_eq!(
|
||||
@@ -284,8 +308,8 @@ fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_repo_agents_md_overwrites_empty_targets() {
|
||||
#[tokio::test]
|
||||
async fn import_repo_agents_md_overwrites_empty_targets() {
|
||||
let root = TempDir::new().expect("create tempdir");
|
||||
let repo_root = root.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).expect("create git");
|
||||
@@ -297,7 +321,9 @@ fn import_repo_agents_md_overwrites_empty_targets() {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: String::new(),
|
||||
cwd: Some(repo_root.clone()),
|
||||
details: None,
|
||||
}])
|
||||
.await
|
||||
.expect("import");
|
||||
|
||||
assert_eq!(
|
||||
@@ -336,12 +362,13 @@ fn detect_repo_prefers_non_empty_dot_claude_agents_source() {
|
||||
repo_root.join("AGENTS.md").display(),
|
||||
),
|
||||
cwd: Some(repo_root),
|
||||
details: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_repo_uses_non_empty_dot_claude_agents_source() {
|
||||
#[tokio::test]
|
||||
async fn import_repo_uses_non_empty_dot_claude_agents_source() {
|
||||
let root = TempDir::new().expect("create tempdir");
|
||||
let repo_root = root.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).expect("create git");
|
||||
@@ -358,7 +385,9 @@ fn import_repo_uses_non_empty_dot_claude_agents_source() {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: String::new(),
|
||||
cwd: Some(repo_root.clone()),
|
||||
details: None,
|
||||
}])
|
||||
.await
|
||||
.expect("import");
|
||||
|
||||
assert_eq!(
|
||||
@@ -378,6 +407,141 @@ fn migration_metric_tags_for_skills_include_skills_count() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_home_lists_enabled_plugins_from_settings() {
|
||||
let (_root, claude_home, codex_home) = fixture_paths();
|
||||
fs::create_dir_all(&claude_home).expect("create claude home");
|
||||
fs::write(
|
||||
claude_home.join("settings.json"),
|
||||
r#"{
|
||||
"enabledPlugins": {
|
||||
"formatter@acme-tools": true,
|
||||
"deployer@acme-tools": true,
|
||||
"analyzer@security-plugins": false
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let items = service_for_paths(claude_home.clone(), codex_home)
|
||||
.detect(ExternalAgentConfigDetectOptions {
|
||||
include_home: true,
|
||||
cwds: None,
|
||||
})
|
||||
.expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: format!(
|
||||
"Import enabled plugins from {}",
|
||||
claude_home.join("settings.json").display()
|
||||
),
|
||||
cwd: None,
|
||||
details: Some(MigrationDetails {
|
||||
plugins: vec![PluginsMigration {
|
||||
marketplace_name: "acme-tools".to_string(),
|
||||
plugin_names: vec!["deployer".to_string(), "formatter".to_string()],
|
||||
}],
|
||||
}),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_plugins_requires_details() {
|
||||
let (_root, claude_home, codex_home) = fixture_paths();
|
||||
|
||||
let err = service_for_paths(claude_home, codex_home)
|
||||
.import_plugins(/*cwd*/ None, /*details*/ None)
|
||||
.await
|
||||
.expect_err("expected missing details error");
|
||||
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
assert_eq!(err.to_string(), "plugins migration item is missing details");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_plugins_requires_source_marketplace_details() {
|
||||
let (_root, claude_home, codex_home) = fixture_paths();
|
||||
fs::create_dir_all(&claude_home).expect("create claude home");
|
||||
fs::write(
|
||||
claude_home.join("settings.json"),
|
||||
r#"{
|
||||
"enabledPlugins": {
|
||||
"formatter@acme-tools": true
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"acme-tools": {
|
||||
"source": "github",
|
||||
"repo": "acme-corp/claude-plugins"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let outcome = service_for_paths(claude_home, codex_home)
|
||||
.import_plugins(
|
||||
/*cwd*/ None,
|
||||
Some(MigrationDetails {
|
||||
plugins: vec![PluginsMigration {
|
||||
marketplace_name: "other-tools".to_string(),
|
||||
plugin_names: github_plugin_details().plugins[0].plugin_names.clone(),
|
||||
}],
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("import plugins");
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
PluginImportOutcome {
|
||||
succeeded_marketplaces: Vec::new(),
|
||||
succeeded_plugin_ids: Vec::new(),
|
||||
failed_marketplaces: vec!["other-tools".to_string()],
|
||||
failed_plugin_ids: vec!["formatter@other-tools".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace() {
|
||||
let (_root, claude_home, codex_home) = fixture_paths();
|
||||
fs::create_dir_all(&claude_home).expect("create claude home");
|
||||
fs::write(
|
||||
claude_home.join("settings.json"),
|
||||
r#"{
|
||||
"enabledPlugins": {
|
||||
"formatter@acme-tools": true
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"acme-tools": {
|
||||
"source": "local",
|
||||
"path": "./external_plugins/acme-tools"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let outcome = service_for_paths(claude_home, codex_home)
|
||||
.import_plugins(/*cwd*/ None, Some(github_plugin_details()))
|
||||
.await
|
||||
.expect("import plugins");
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
PluginImportOutcome {
|
||||
succeeded_marketplaces: Vec::new(),
|
||||
succeeded_plugin_ids: Vec::new(),
|
||||
failed_marketplaces: vec!["acme-tools".to_string()],
|
||||
failed_plugin_ids: vec!["formatter@acme-tools".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_skills_returns_only_new_skill_directory_count() {
|
||||
let (_root, claude_home, codex_home) = fixture_paths();
|
||||
|
||||
@@ -163,14 +163,14 @@ async fn run_guardian_review(
|
||||
|
||||
let schema = guardian_output_schema();
|
||||
let terminal_action = action_summary.clone();
|
||||
let outcome = run_guardian_review_session(
|
||||
let outcome = Box::pin(run_guardian_review_session(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
request,
|
||||
retry_reason,
|
||||
schema,
|
||||
external_cancel,
|
||||
)
|
||||
))
|
||||
.await;
|
||||
|
||||
let assessment = match outcome {
|
||||
@@ -303,14 +303,16 @@ pub(crate) async fn review_approval_request(
|
||||
request: GuardianApprovalRequest,
|
||||
retry_reason: Option<String>,
|
||||
) -> ReviewDecision {
|
||||
run_guardian_review(
|
||||
// Box the delegated review future so callers do not inline the entire
|
||||
// guardian session state machine into their own async stack.
|
||||
Box::pin(run_guardian_review(
|
||||
Arc::clone(session),
|
||||
Arc::clone(turn),
|
||||
review_id,
|
||||
request,
|
||||
retry_reason,
|
||||
/*external_cancel*/ None,
|
||||
)
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -322,14 +324,14 @@ pub(crate) async fn review_approval_request_with_cancel(
|
||||
retry_reason: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> ReviewDecision {
|
||||
run_guardian_review(
|
||||
Box::pin(run_guardian_review(
|
||||
Arc::clone(session),
|
||||
Arc::clone(turn),
|
||||
review_id,
|
||||
request,
|
||||
retry_reason,
|
||||
Some(cancel_token),
|
||||
)
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -411,22 +413,24 @@ pub(super) async fn run_guardian_review_session(
|
||||
Err(err) => return GuardianReviewOutcome::Completed(Err(err)),
|
||||
};
|
||||
|
||||
match session
|
||||
.guardian_review_session
|
||||
.run_review(GuardianReviewSessionParams {
|
||||
parent_session: Arc::clone(&session),
|
||||
parent_turn: turn.clone(),
|
||||
spawn_config: guardian_config,
|
||||
request,
|
||||
retry_reason,
|
||||
schema,
|
||||
model: guardian_model,
|
||||
reasoning_effort: guardian_reasoning_effort,
|
||||
reasoning_summary: turn.reasoning_summary,
|
||||
personality: turn.personality,
|
||||
external_cancel,
|
||||
})
|
||||
.await
|
||||
match Box::pin(
|
||||
session
|
||||
.guardian_review_session
|
||||
.run_review(GuardianReviewSessionParams {
|
||||
parent_session: Arc::clone(&session),
|
||||
parent_turn: turn.clone(),
|
||||
spawn_config: guardian_config,
|
||||
request,
|
||||
retry_reason,
|
||||
schema,
|
||||
model: guardian_model,
|
||||
reasoning_effort: guardian_reasoning_effort,
|
||||
reasoning_summary: turn.reasoning_summary,
|
||||
personality: turn.personality,
|
||||
external_cancel,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => {
|
||||
GuardianReviewOutcome::Completed(parse_guardian_assessment(
|
||||
|
||||
@@ -327,32 +327,30 @@ impl GuardianReviewSessionManager {
|
||||
};
|
||||
|
||||
if trunk.reuse_key != next_reuse_key {
|
||||
return self
|
||||
.run_ephemeral_review(
|
||||
params,
|
||||
next_reuse_key,
|
||||
deadline,
|
||||
/*fork_snapshot*/ None,
|
||||
)
|
||||
.await;
|
||||
return Box::pin(self.run_ephemeral_review(
|
||||
params,
|
||||
next_reuse_key,
|
||||
deadline,
|
||||
/*fork_snapshot*/ None,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
let trunk_guard = match trunk.review_lock.try_lock() {
|
||||
Ok(trunk_guard) => trunk_guard,
|
||||
Err(_) => {
|
||||
return self
|
||||
.run_ephemeral_review(
|
||||
params,
|
||||
next_reuse_key,
|
||||
deadline,
|
||||
trunk.fork_snapshot().await,
|
||||
)
|
||||
.await;
|
||||
return Box::pin(self.run_ephemeral_review(
|
||||
params,
|
||||
next_reuse_key,
|
||||
deadline,
|
||||
trunk.fork_snapshot().await,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
};
|
||||
|
||||
let (outcome, keep_review_session) =
|
||||
run_review_on_session(trunk.as_ref(), ¶ms, deadline).await;
|
||||
Box::pin(run_review_on_session(trunk.as_ref(), ¶ms, deadline)).await;
|
||||
if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) {
|
||||
trunk.refresh_last_committed_fork_snapshot().await;
|
||||
}
|
||||
@@ -488,7 +486,12 @@ impl GuardianReviewSessionManager {
|
||||
let mut cleanup =
|
||||
EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session));
|
||||
|
||||
let (outcome, _) = run_review_on_session(review_session.as_ref(), ¶ms, deadline).await;
|
||||
let (outcome, _) = Box::pin(run_review_on_session(
|
||||
review_session.as_ref(),
|
||||
¶ms,
|
||||
deadline,
|
||||
))
|
||||
.await;
|
||||
if let Some(review_session) = self.take_active_ephemeral(&review_session).await {
|
||||
cleanup.disarm();
|
||||
review_session.shutdown_in_background();
|
||||
@@ -512,7 +515,7 @@ async fn spawn_guardian_review_session(
|
||||
),
|
||||
None => (None, 0, None),
|
||||
};
|
||||
let codex = run_codex_thread_interactive(
|
||||
let codex = Box::pin(run_codex_thread_interactive(
|
||||
spawn_config,
|
||||
params.parent_session.services.auth_manager.clone(),
|
||||
params.parent_session.services.models_manager.clone(),
|
||||
@@ -521,7 +524,7 @@ async fn spawn_guardian_review_session(
|
||||
cancel_token.clone(),
|
||||
SubAgentSource::Other(GUARDIAN_REVIEWER_NAME.to_string()),
|
||||
initial_history,
|
||||
)
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(GuardianReviewSession {
|
||||
@@ -745,6 +748,7 @@ pub(crate) fn build_guardian_review_session_config(
|
||||
for feature in [
|
||||
Feature::SpawnCsv,
|
||||
Feature::Collab,
|
||||
Feature::CodexHooks,
|
||||
Feature::WebSearchRequest,
|
||||
Feature::WebSearchCached,
|
||||
] {
|
||||
@@ -851,6 +855,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_session_config_disables_hooks() {
|
||||
let mut parent_config = crate::config::test_config();
|
||||
parent_config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("enable hooks on parent config");
|
||||
|
||||
let guardian_config = build_guardian_review_session_config(
|
||||
&parent_config,
|
||||
/*live_network_config*/ None,
|
||||
"active-model",
|
||||
/*reasoning_effort*/ None,
|
||||
)
|
||||
.expect("guardian config");
|
||||
|
||||
assert!(!guardian_config.features.enabled(Feature::CodexHooks));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn run_before_review_deadline_times_out_before_future_completes() {
|
||||
let outcome = run_before_review_deadline(
|
||||
|
||||
+224
-204
@@ -1336,222 +1336,242 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> {
|
||||
let first_assessment = serde_json::json!({
|
||||
"risk_level": "low",
|
||||
"user_authorization": "high",
|
||||
"outcome": "allow",
|
||||
"rationale": "first guardian rationale",
|
||||
})
|
||||
.to_string();
|
||||
let second_assessment = serde_json::json!({
|
||||
"risk_level": "low",
|
||||
"user_authorization": "high",
|
||||
"outcome": "allow",
|
||||
"rationale": "second guardian rationale",
|
||||
})
|
||||
.to_string();
|
||||
let third_assessment = serde_json::json!({
|
||||
"risk_level": "low",
|
||||
"user_authorization": "high",
|
||||
"outcome": "allow",
|
||||
"rationale": "third guardian rationale",
|
||||
})
|
||||
.to_string();
|
||||
let (gate_tx, gate_rx) = tokio::sync::oneshot::channel();
|
||||
let (server, _) = start_streaming_sse_server(vec![
|
||||
vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse(vec![
|
||||
ev_response_created("resp-guardian-1"),
|
||||
ev_assistant_message("msg-guardian-1", &first_assessment),
|
||||
ev_completed("resp-guardian-1"),
|
||||
]),
|
||||
}],
|
||||
vec![
|
||||
StreamingSseChunk {
|
||||
#[test]
|
||||
fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> {
|
||||
const TEST_STACK_SIZE_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
let handle =
|
||||
std::thread::Builder::new()
|
||||
.name("guardian_parallel_reviews_fork_from_last_committed_trunk_history".to_string())
|
||||
.stack_size(TEST_STACK_SIZE_BYTES)
|
||||
.spawn(|| -> anyhow::Result<()> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime.block_on(Box::pin(async {
|
||||
let first_assessment = serde_json::json!({
|
||||
"risk_level": "low",
|
||||
"user_authorization": "high",
|
||||
"outcome": "allow",
|
||||
"rationale": "first guardian rationale",
|
||||
})
|
||||
.to_string();
|
||||
let second_assessment = serde_json::json!({
|
||||
"risk_level": "low",
|
||||
"user_authorization": "high",
|
||||
"outcome": "allow",
|
||||
"rationale": "second guardian rationale",
|
||||
})
|
||||
.to_string();
|
||||
let third_assessment = serde_json::json!({
|
||||
"risk_level": "low",
|
||||
"user_authorization": "high",
|
||||
"outcome": "allow",
|
||||
"rationale": "third guardian rationale",
|
||||
})
|
||||
.to_string();
|
||||
let (gate_tx, gate_rx) = tokio::sync::oneshot::channel();
|
||||
let (server, _) = start_streaming_sse_server(vec![
|
||||
vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse(vec![ev_response_created("resp-guardian-2")]),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(gate_rx),
|
||||
body: sse(vec![
|
||||
ev_assistant_message("msg-guardian-2", &second_assessment),
|
||||
ev_completed("resp-guardian-2"),
|
||||
ev_response_created("resp-guardian-1"),
|
||||
ev_assistant_message("msg-guardian-1", &first_assessment),
|
||||
ev_completed("resp-guardian-1"),
|
||||
]),
|
||||
},
|
||||
],
|
||||
vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse(vec![
|
||||
ev_response_created("resp-guardian-3"),
|
||||
ev_assistant_message("msg-guardian-3", &third_assessment),
|
||||
ev_completed("resp-guardian-3"),
|
||||
]),
|
||||
}],
|
||||
])
|
||||
.await;
|
||||
}],
|
||||
vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse(vec![ev_response_created("resp-guardian-2")]),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(gate_rx),
|
||||
body: sse(vec![
|
||||
ev_assistant_message("msg-guardian-2", &second_assessment),
|
||||
ev_completed("resp-guardian-2"),
|
||||
]),
|
||||
},
|
||||
],
|
||||
vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse(vec![
|
||||
ev_response_created("resp-guardian-3"),
|
||||
ev_assistant_message("msg-guardian-3", &third_assessment),
|
||||
ev_completed("resp-guardian-3"),
|
||||
]),
|
||||
}],
|
||||
])
|
||||
.await;
|
||||
|
||||
let (session, turn) = guardian_test_session_and_turn_with_base_url(server.uri()).await;
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
let (session, turn) = guardian_test_session_and_turn_with_base_url(server.uri()).await;
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
|
||||
let initial_request = GuardianApprovalRequest::Shell {
|
||||
id: "shell-guardian-1".to_string(),
|
||||
command: vec!["git".to_string(), "status".to_string()],
|
||||
cwd: test_path_buf("/repo/codex-rs/core").abs(),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: Some("Inspect repo state before proceeding.".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
review_approval_request(
|
||||
let initial_request = GuardianApprovalRequest::Shell {
|
||||
id: "shell-guardian-1".to_string(),
|
||||
command: vec!["git".to_string(), "status".to_string()],
|
||||
cwd: test_path_buf("/repo/codex-rs/core").abs(),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: Some("Inspect repo state before proceeding.".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
review_approval_request(
|
||||
&session,
|
||||
&turn,
|
||||
"review-shell-guardian-1".to_string(),
|
||||
initial_request,
|
||||
/*retry_reason*/ None
|
||||
)
|
||||
.await,
|
||||
ReviewDecision::Approved
|
||||
);
|
||||
session
|
||||
.record_into_history(
|
||||
&[
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "Please inspect pending changes before pushing.".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "I need approval to run git diff.".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
],
|
||||
turn.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let second_request = GuardianApprovalRequest::Shell {
|
||||
id: "shell-guardian-2".to_string(),
|
||||
command: vec!["git".to_string(), "diff".to_string()],
|
||||
cwd: test_path_buf("/repo/codex-rs/core").abs(),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: Some("Inspect pending changes before proceeding.".to_string()),
|
||||
};
|
||||
let third_request = GuardianApprovalRequest::Shell {
|
||||
id: "shell-guardian-3".to_string(),
|
||||
command: vec!["git".to_string(), "push".to_string()],
|
||||
cwd: test_path_buf("/repo/codex-rs/core").abs(),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: Some("Inspect whether pushing is safe before proceeding.".to_string()),
|
||||
};
|
||||
|
||||
let session_for_second = Arc::clone(&session);
|
||||
let turn_for_second = Arc::clone(&turn);
|
||||
let mut second_review = tokio::spawn(async move {
|
||||
review_approval_request(
|
||||
&session_for_second,
|
||||
&turn_for_second,
|
||||
"review-shell-guardian-2".to_string(),
|
||||
second_request,
|
||||
Some("trunk follow-up".to_string()),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
let second_request_observed = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if server.requests().await.len() >= 2 {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
second_request_observed.is_ok(),
|
||||
"second guardian request was not observed"
|
||||
);
|
||||
session
|
||||
.record_into_history(
|
||||
&[
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "Now inspect whether pushing is safe.".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "I need approval to push after the diff check.".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
],
|
||||
turn.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let third_decision = review_approval_request(
|
||||
&session,
|
||||
&turn,
|
||||
"review-shell-guardian-1".to_string(),
|
||||
initial_request,
|
||||
/*retry_reason*/ None
|
||||
)
|
||||
.await,
|
||||
ReviewDecision::Approved
|
||||
);
|
||||
session
|
||||
.record_into_history(
|
||||
&[
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "Please inspect pending changes before pushing.".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "I need approval to run git diff.".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
],
|
||||
turn.as_ref(),
|
||||
"review-shell-guardian-3".to_string(),
|
||||
third_request,
|
||||
Some("parallel follow-up".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(third_decision, ReviewDecision::Approved);
|
||||
let requests = server.requests().await;
|
||||
assert_eq!(requests.len(), 3);
|
||||
let third_request_body = serde_json::from_slice::<serde_json::Value>(&requests[2])?;
|
||||
let third_request_body_text = third_request_body.to_string();
|
||||
assert!(
|
||||
third_request_body_text.contains("first guardian rationale"),
|
||||
"forked guardian review should include the last committed trunk assessment"
|
||||
);
|
||||
let third_user_message = last_user_message_text_from_body(&third_request_body);
|
||||
assert!(third_user_message.contains(">>> TRANSCRIPT DELTA START\n"));
|
||||
assert!(
|
||||
third_user_message.contains("[5] user: Please inspect pending changes before pushing.")
|
||||
);
|
||||
assert!(third_user_message.contains("[7] user: Now inspect whether pushing is safe."));
|
||||
assert!(!third_user_message.contains("[1] user: Please check the repo visibility"));
|
||||
assert!(
|
||||
!third_request_body_text.contains("second guardian rationale"),
|
||||
"forked guardian review should not include the still in-flight trunk assessment"
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), &mut second_review)
|
||||
.await
|
||||
.is_err(),
|
||||
"the trunk guardian review should still be blocked on its gated response"
|
||||
);
|
||||
|
||||
let second_request = GuardianApprovalRequest::Shell {
|
||||
id: "shell-guardian-2".to_string(),
|
||||
command: vec!["git".to_string(), "diff".to_string()],
|
||||
cwd: test_path_buf("/repo/codex-rs/core").abs(),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: Some("Inspect pending changes before proceeding.".to_string()),
|
||||
};
|
||||
let third_request = GuardianApprovalRequest::Shell {
|
||||
id: "shell-guardian-3".to_string(),
|
||||
command: vec!["git".to_string(), "push".to_string()],
|
||||
cwd: test_path_buf("/repo/codex-rs/core").abs(),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: Some("Inspect whether pushing is safe before proceeding.".to_string()),
|
||||
};
|
||||
gate_tx
|
||||
.send(())
|
||||
.expect("second guardian review gate should still be open");
|
||||
assert_eq!(second_review.await?, ReviewDecision::Approved);
|
||||
server.shutdown().await;
|
||||
|
||||
let session_for_second = Arc::clone(&session);
|
||||
let turn_for_second = Arc::clone(&turn);
|
||||
let mut second_review = tokio::spawn(async move {
|
||||
review_approval_request(
|
||||
&session_for_second,
|
||||
&turn_for_second,
|
||||
"review-shell-guardian-2".to_string(),
|
||||
second_request,
|
||||
Some("trunk follow-up".to_string()),
|
||||
)
|
||||
.await
|
||||
});
|
||||
Ok(())
|
||||
}))
|
||||
})?;
|
||||
|
||||
let second_request_observed = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if server.requests().await.len() >= 2 {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
second_request_observed.is_ok(),
|
||||
"second guardian request was not observed"
|
||||
);
|
||||
session
|
||||
.record_into_history(
|
||||
&[
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "Now inspect whether pushing is safe.".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "I need approval to push after the diff check.".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
],
|
||||
turn.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let third_decision = review_approval_request(
|
||||
&session,
|
||||
&turn,
|
||||
"review-shell-guardian-3".to_string(),
|
||||
third_request,
|
||||
Some("parallel follow-up".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(third_decision, ReviewDecision::Approved);
|
||||
let requests = server.requests().await;
|
||||
assert_eq!(requests.len(), 3);
|
||||
let third_request_body = serde_json::from_slice::<serde_json::Value>(&requests[2])?;
|
||||
let third_request_body_text = third_request_body.to_string();
|
||||
assert!(
|
||||
third_request_body_text.contains("first guardian rationale"),
|
||||
"forked guardian review should include the last committed trunk assessment"
|
||||
);
|
||||
let third_user_message = last_user_message_text_from_body(&third_request_body);
|
||||
assert!(third_user_message.contains(">>> TRANSCRIPT DELTA START\n"));
|
||||
assert!(
|
||||
third_user_message.contains("[5] user: Please inspect pending changes before pushing.")
|
||||
);
|
||||
assert!(third_user_message.contains("[7] user: Now inspect whether pushing is safe."));
|
||||
assert!(!third_user_message.contains("[1] user: Please check the repo visibility"));
|
||||
assert!(
|
||||
!third_request_body_text.contains("second guardian rationale"),
|
||||
"forked guardian review should not include the still in-flight trunk assessment"
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), &mut second_review)
|
||||
.await
|
||||
.is_err(),
|
||||
"the trunk guardian review should still be blocked on its gated response"
|
||||
);
|
||||
|
||||
gate_tx
|
||||
.send(())
|
||||
.expect("second guardian review gate should still be open");
|
||||
assert_eq!(second_review.await?, ReviewDecision::Approved);
|
||||
server.shutdown().await;
|
||||
|
||||
Ok(())
|
||||
match handle.join() {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"guardian_parallel_reviews_fork_from_last_committed_trunk_history thread panicked"
|
||||
)),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn guardian_review_session_config_preserves_parent_network_proxy() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// the TUI or the tracing stack).
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
mod agent_identity;
|
||||
mod apply_patch;
|
||||
mod apps;
|
||||
mod arc_monitor;
|
||||
@@ -140,7 +141,6 @@ pub use project_doc::discover_project_doc_paths;
|
||||
pub use project_doc::read_project_docs;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
pub mod seatbelt;
|
||||
mod session_rollout_init_error;
|
||||
pub mod shell;
|
||||
pub(crate) mod shell_snapshot;
|
||||
|
||||
@@ -36,6 +36,7 @@ use codex_analytics::build_track_events_context;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_features::Feature;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::SandboxState;
|
||||
use codex_mcp::declared_openai_file_input_param_names;
|
||||
use codex_mcp::mcp_permission_prompt_is_auto_approved;
|
||||
use codex_otel::sanitize_metric_tag_value;
|
||||
@@ -466,6 +467,10 @@ async fn execute_mcp_tool_call(
|
||||
metadata.and_then(|metadata| metadata.openai_file_input_params.as_deref()),
|
||||
)
|
||||
.await?;
|
||||
let request_meta =
|
||||
augment_mcp_tool_request_meta_with_sandbox_state(sess, turn_context, server, request_meta)
|
||||
.await
|
||||
.map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?;
|
||||
let result = sess
|
||||
.call_tool(server, tool_name, rewritten_arguments, request_meta)
|
||||
.await
|
||||
@@ -479,6 +484,52 @@ async fn execute_mcp_tool_call(
|
||||
)
|
||||
}
|
||||
|
||||
async fn augment_mcp_tool_request_meta_with_sandbox_state(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
server: &str,
|
||||
mut meta: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<Option<serde_json::Value>> {
|
||||
let supports_sandbox_state_meta = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.server_supports_sandbox_state_meta_capability(server)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !supports_sandbox_state_meta {
|
||||
return Ok(meta);
|
||||
}
|
||||
|
||||
let sandbox_state = serde_json::to_value(SandboxState {
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: turn_context.cwd.to_path_buf(),
|
||||
use_legacy_landlock: turn_context.features.use_legacy_landlock(),
|
||||
})?;
|
||||
|
||||
match meta.as_mut() {
|
||||
Some(serde_json::Value::Object(map)) => {
|
||||
map.insert(
|
||||
codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY.to_string(),
|
||||
sandbox_state,
|
||||
);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert(
|
||||
codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY.to_string(),
|
||||
sandbox_state,
|
||||
);
|
||||
meta = Some(serde_json::Value::Object(map));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
async fn maybe_mark_thread_memory_mode_polluted(sess: &Session, turn_context: &TurnContext) {
|
||||
if !turn_context
|
||||
.config
|
||||
|
||||
@@ -41,9 +41,13 @@ pub(crate) fn build_mcp_tool_exposure(
|
||||
|
||||
let direct_tools =
|
||||
filter_codex_apps_mcp_tools(all_mcp_tools, explicitly_enabled_connectors, config);
|
||||
for direct_tool_name in direct_tools.keys() {
|
||||
deferred_tools.remove(direct_tool_name);
|
||||
}
|
||||
|
||||
McpToolExposure {
|
||||
direct_tools,
|
||||
deferred_tools: Some(deferred_tools),
|
||||
deferred_tools: (!deferred_tools.is_empty()).then_some(deferred_tools),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,11 @@ What it does:
|
||||
- `raw_memories.md` (merged raw memories, latest first)
|
||||
- `rollout_summaries/` (one summary file per retained rollout)
|
||||
- prunes stale rollout summaries that are no longer retained
|
||||
- if there are no inputs, marks the job successful and exits
|
||||
- finds old resource files from memory extensions under
|
||||
`memories_extensions/<extension>/resources/` for extension directories that
|
||||
have an `instructions.md`, using the memory module retention window
|
||||
- if there are no Phase 1 inputs or old extension resources, marks the job
|
||||
successful and exits
|
||||
|
||||
If there is input, it then:
|
||||
|
||||
@@ -92,10 +96,13 @@ If there is input, it then:
|
||||
- builds the Phase 2 prompt with a diff of the current Phase 1 input
|
||||
selection versus the last successful Phase 2 selection (`added`,
|
||||
`retained`, `removed`)
|
||||
- includes old extension resource paths in the prompt diff
|
||||
- runs it with no approvals, no network, and local write access only
|
||||
- disables collab for that agent (to prevent recursive delegation)
|
||||
- watches the agent status and heartbeats the global job lease while it runs
|
||||
- marks the phase-2 job success/failure in the state DB when the agent finishes
|
||||
- prunes old extension resource files after the consolidation agent completes
|
||||
and the successful Phase 2 job is recorded
|
||||
|
||||
Selection diff behavior:
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
use crate::memories::memory_extensions_root;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::warn;
|
||||
|
||||
const FILENAME_TS_FORMAT: &str = "%Y-%m-%dT%H-%M-%S";
|
||||
pub(super) const EXTENSION_RESOURCE_RETENTION_DAYS: i64 = 7;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct RemovedExtensionResource {
|
||||
pub(super) extension: String,
|
||||
pub(super) resource_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct PendingExtensionResourceRemoval {
|
||||
pub(super) removed: RemovedExtensionResource,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) async fn find_old_extension_resources(
|
||||
memory_root: &Path,
|
||||
) -> Vec<PendingExtensionResourceRemoval> {
|
||||
find_old_extension_resources_with_now(memory_root, Utc::now()).await
|
||||
}
|
||||
|
||||
async fn find_old_extension_resources_with_now(
|
||||
memory_root: &Path,
|
||||
now: DateTime<Utc>,
|
||||
) -> Vec<PendingExtensionResourceRemoval> {
|
||||
let mut pending = Vec::new();
|
||||
let cutoff = now - Duration::days(EXTENSION_RESOURCE_RETENTION_DAYS);
|
||||
let extensions_root = memory_extensions_root(memory_root);
|
||||
let mut extensions = match tokio::fs::read_dir(&extensions_root).await {
|
||||
Ok(extensions) => extensions,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return pending,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed reading memory extensions root {}: {err}",
|
||||
extensions_root.display()
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(Some(extension_entry)) = extensions.next_entry().await {
|
||||
let extension_path = extension_entry.path();
|
||||
let Ok(file_type) = extension_entry.file_type().await else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(extension) = extension_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(ToOwned::to_owned)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !tokio::fs::try_exists(extension_path.join("instructions.md"))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let resources_path = extension_path.join("resources");
|
||||
let mut resources = match tokio::fs::read_dir(&resources_path).await {
|
||||
Ok(resources) => resources,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed reading memory extension resources {}: {err}",
|
||||
resources_path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(Some(resource_entry)) = resources.next_entry().await {
|
||||
let resource_file_path = resource_entry.path();
|
||||
let Ok(file_type) = resource_entry.file_type().await else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(file_name) = resource_file_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !file_name.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
let Some(resource_timestamp) = resource_timestamp(file_name) else {
|
||||
continue;
|
||||
};
|
||||
if resource_timestamp > cutoff {
|
||||
continue;
|
||||
}
|
||||
|
||||
pending.push(PendingExtensionResourceRemoval {
|
||||
removed: RemovedExtensionResource {
|
||||
extension: extension.clone(),
|
||||
resource_path: format!("resources/{file_name}"),
|
||||
},
|
||||
path: resource_file_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pending.sort_by(|left, right| {
|
||||
left.removed
|
||||
.extension
|
||||
.cmp(&right.removed.extension)
|
||||
.then_with(|| left.removed.resource_path.cmp(&right.removed.resource_path))
|
||||
});
|
||||
pending
|
||||
}
|
||||
|
||||
pub(super) async fn remove_extension_resources(resources: &[PendingExtensionResourceRemoval]) {
|
||||
for resource in resources {
|
||||
if let Err(err) = tokio::fs::remove_file(&resource.path).await
|
||||
&& err.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
warn!(
|
||||
"failed pruning old memory extension resource {}: {err}",
|
||||
resource.path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_timestamp(file_name: &str) -> Option<DateTime<Utc>> {
|
||||
let timestamp = file_name.get(..19)?;
|
||||
let naive = NaiveDateTime::parse_from_str(timestamp, FILENAME_TS_FORMAT).ok()?;
|
||||
Some(DateTime::from_naive_utc_and_offset(naive, Utc))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn finds_only_old_resources_from_extensions_with_instructions() {
|
||||
let codex_home = TempDir::new().expect("create temp codex home");
|
||||
let memory_root = codex_home.path().join("memories");
|
||||
let extensions_root = memory_extensions_root(&memory_root);
|
||||
let telepathy_resources = extensions_root.join("telepathy/resources");
|
||||
tokio::fs::create_dir_all(&telepathy_resources)
|
||||
.await
|
||||
.expect("create telepathy resources");
|
||||
tokio::fs::write(
|
||||
extensions_root.join("telepathy/instructions.md"),
|
||||
"instructions",
|
||||
)
|
||||
.await
|
||||
.expect("write telepathy instructions");
|
||||
|
||||
let now = DateTime::from_naive_utc_and_offset(
|
||||
NaiveDateTime::parse_from_str("2026-04-14T12-00-00", FILENAME_TS_FORMAT)
|
||||
.expect("parse now"),
|
||||
Utc,
|
||||
);
|
||||
let old_file = telepathy_resources.join("2026-04-06T11-59-59-abcd-10min-old.md");
|
||||
let exact_cutoff_file =
|
||||
telepathy_resources.join("2026-04-07T12-00-00-abcd-10min-cutoff.md");
|
||||
let recent_file = telepathy_resources.join("2026-04-08T12-00-00-abcd-10min-recent.md");
|
||||
let invalid_file = telepathy_resources.join("not-a-timestamp.md");
|
||||
for file in [&old_file, &exact_cutoff_file, &recent_file, &invalid_file] {
|
||||
tokio::fs::write(file, "resource")
|
||||
.await
|
||||
.expect("write telepathy resource");
|
||||
}
|
||||
|
||||
let ignored_resources = extensions_root.join("ignored/resources");
|
||||
tokio::fs::create_dir_all(&ignored_resources)
|
||||
.await
|
||||
.expect("create ignored resources");
|
||||
let ignored_old_file = ignored_resources.join("2026-04-06T11-59-59-abcd-10min-old.md");
|
||||
tokio::fs::write(&ignored_old_file, "ignored")
|
||||
.await
|
||||
.expect("write ignored resource");
|
||||
|
||||
let pending = find_old_extension_resources_with_now(&memory_root, now).await;
|
||||
|
||||
assert_eq!(
|
||||
pending
|
||||
.iter()
|
||||
.map(|resource| resource.removed.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
RemovedExtensionResource {
|
||||
extension: "telepathy".to_string(),
|
||||
resource_path: "resources/2026-04-06T11-59-59-abcd-10min-old.md".to_string(),
|
||||
},
|
||||
RemovedExtensionResource {
|
||||
extension: "telepathy".to_string(),
|
||||
resource_path: "resources/2026-04-07T12-00-00-abcd-10min-cutoff.md".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&old_file)
|
||||
.await
|
||||
.expect("check old file before remove")
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&exact_cutoff_file)
|
||||
.await
|
||||
.expect("check cutoff file before remove")
|
||||
);
|
||||
|
||||
remove_extension_resources(&pending).await;
|
||||
|
||||
assert!(
|
||||
!tokio::fs::try_exists(&old_file)
|
||||
.await
|
||||
.expect("check old file")
|
||||
);
|
||||
assert!(
|
||||
!tokio::fs::try_exists(&exact_cutoff_file)
|
||||
.await
|
||||
.expect("check cutoff file")
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&recent_file)
|
||||
.await
|
||||
.expect("check recent file")
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&invalid_file)
|
||||
.await
|
||||
.expect("check invalid file")
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&ignored_old_file)
|
||||
.await
|
||||
.expect("check ignored old file")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ mod artifacts {
|
||||
pub(super) const RAW_MEMORIES_FILENAME: &str = "raw_memories.md";
|
||||
}
|
||||
|
||||
mod extensions;
|
||||
|
||||
/// Phase 1 (startup extraction).
|
||||
mod phase_one {
|
||||
/// Default model used for phase 1.
|
||||
|
||||
@@ -477,9 +477,10 @@ mod job {
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string(&filtered).map_err(|err| {
|
||||
let serialized = serde_json::to_string(&filtered).map_err(|err| {
|
||||
CodexErr::InvalidRequest(format!("failed to serialize rollout memory: {err}"))
|
||||
})
|
||||
})?;
|
||||
Ok(redact_secrets(serialized))
|
||||
}
|
||||
|
||||
fn sanitize_response_item_for_memories(item: &ResponseItem) -> Option<ResponseItem> {
|
||||
|
||||
@@ -3,6 +3,8 @@ use super::JobResult;
|
||||
use super::aggregate_stats;
|
||||
use super::job::serialize_filtered_rollout_response_items;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
@@ -72,6 +74,25 @@ fn serializes_memory_rollout_with_agents_removed_but_environment_kept() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_memory_rollout_redacts_secrets_before_prompt_upload() {
|
||||
let serialized = serialize_filtered_rollout_response_items(&[RolloutItem::ResponseItem(
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "call_123".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(
|
||||
r#"{"token":"sk-abcdefghijklmnopqrstuvwxyz123456"}"#.to_string(),
|
||||
),
|
||||
success: Some(true),
|
||||
},
|
||||
},
|
||||
)])
|
||||
.expect("serialize");
|
||||
|
||||
assert!(!serialized.contains("sk-abcdefghijklmnopqrstuvwxyz123456"));
|
||||
assert!(serialized.contains("[REDACTED_SECRET]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_outcomes_sums_token_usage_across_all_jobs() {
|
||||
let counts = aggregate_stats(vec![
|
||||
|
||||
@@ -3,6 +3,9 @@ use crate::agent::status::is_final as is_final_agent_status;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::emit_subagent_session_started;
|
||||
use crate::config::Config;
|
||||
use crate::memories::extensions::PendingExtensionResourceRemoval;
|
||||
use crate::memories::extensions::find_old_extension_resources;
|
||||
use crate::memories::extensions::remove_extension_resources;
|
||||
use crate::memories::memory_root;
|
||||
use crate::memories::metrics;
|
||||
use crate::memories::phase_two;
|
||||
@@ -112,7 +115,12 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
job::failed(session, db, &claim, "failed_rebuild_raw_memories").await;
|
||||
return;
|
||||
}
|
||||
if raw_memories.is_empty() {
|
||||
let pending_extension_resource_removals = find_old_extension_resources(&root).await;
|
||||
let removed_extension_resources = pending_extension_resource_removals
|
||||
.iter()
|
||||
.map(|resource| resource.removed.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if raw_memories.is_empty() && pending_extension_resource_removals.is_empty() {
|
||||
// We check only after sync of the file system.
|
||||
job::succeed(
|
||||
session,
|
||||
@@ -127,7 +135,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
}
|
||||
|
||||
// 5. Spawn the agent
|
||||
let prompt = agent::get_prompt(config, &selection);
|
||||
let prompt = agent::get_prompt(config, &selection, &removed_extension_resources);
|
||||
let source = SessionSource::SubAgent(SubAgentSource::MemoryConsolidation);
|
||||
let thread_id = match session
|
||||
.services
|
||||
@@ -170,6 +178,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
claim,
|
||||
new_watermark,
|
||||
raw_memories.clone(),
|
||||
pending_extension_resource_removals,
|
||||
thread_id,
|
||||
phase_two_e2e_timer,
|
||||
);
|
||||
@@ -268,15 +277,15 @@ mod job {
|
||||
completion_watermark: i64,
|
||||
selected_outputs: &[codex_state::Stage1Output],
|
||||
reason: &'static str,
|
||||
) {
|
||||
) -> bool {
|
||||
session.services.session_telemetry.counter(
|
||||
metrics::MEMORY_PHASE_TWO_JOBS,
|
||||
/*inc*/ 1,
|
||||
&[("status", reason)],
|
||||
);
|
||||
let _ = db
|
||||
.mark_global_phase2_job_succeeded(&claim.token, completion_watermark, selected_outputs)
|
||||
.await;
|
||||
db.mark_global_phase2_job_succeeded(&claim.token, completion_watermark, selected_outputs)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,9 +337,10 @@ mod agent {
|
||||
pub(super) fn get_prompt(
|
||||
config: Arc<Config>,
|
||||
selection: &codex_state::Phase2InputSelection,
|
||||
removed_extension_resources: &[crate::memories::extensions::RemovedExtensionResource],
|
||||
) -> Vec<UserInput> {
|
||||
let root = memory_root(&config.codex_home);
|
||||
let prompt = build_consolidation_prompt(&root, selection);
|
||||
let prompt = build_consolidation_prompt(&root, selection, removed_extension_resources);
|
||||
vec![UserInput::Text {
|
||||
text: prompt,
|
||||
text_elements: vec![],
|
||||
@@ -343,6 +353,7 @@ mod agent {
|
||||
claim: Claim,
|
||||
new_watermark: i64,
|
||||
selected_outputs: Vec<codex_state::Stage1Output>,
|
||||
pending_extension_resource_removals: Vec<PendingExtensionResourceRemoval>,
|
||||
thread_id: ThreadId,
|
||||
phase_two_e2e_timer: Option<codex_otel::Timer>,
|
||||
) {
|
||||
@@ -379,7 +390,7 @@ mod agent {
|
||||
if let Some(token_usage) = agent_control.get_total_token_usage(thread_id).await {
|
||||
emit_token_usage_metrics(&session, &token_usage);
|
||||
}
|
||||
job::succeed(
|
||||
if job::succeed(
|
||||
&session,
|
||||
&db,
|
||||
&claim,
|
||||
@@ -387,7 +398,10 @@ mod agent {
|
||||
&selected_outputs,
|
||||
"succeeded",
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
remove_extension_resources(&pending_extension_resource_removals).await;
|
||||
}
|
||||
} else {
|
||||
job::failed(&session, &db, &claim, "failed_agent").await;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::memories::extensions::EXTENSION_RESOURCE_RETENTION_DAYS;
|
||||
use crate::memories::extensions::RemovedExtensionResource;
|
||||
use crate::memories::memory_extensions_root;
|
||||
use crate::memories::memory_root;
|
||||
use crate::memories::phase_one;
|
||||
@@ -10,6 +12,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use codex_utils_template::Template;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
use tokio::fs;
|
||||
@@ -62,8 +65,9 @@ Memory extensions (under {{ memory_extensions_root }}/):
|
||||
source.
|
||||
|
||||
If the user has any memory extensions, you MUST read the instructions for each extension to
|
||||
determine how to use the memory source. If it has no extension folders, continue with the standard
|
||||
memory inputs only.
|
||||
determine how to use the memory source. If the Phase 2 diff lists removed memory extension
|
||||
resources, use that extension-specific deletion diff to remove stale memories derived only from
|
||||
those resources. If it has no extension folders, continue with the standard memory inputs only.
|
||||
"#;
|
||||
|
||||
const MEMORY_EXTENSIONS_PRIMARY_INPUTS: &str = r#"
|
||||
@@ -73,12 +77,16 @@ Under `{{ memory_extensions_root }}/`:
|
||||
- `<extension_name>/instructions.md`
|
||||
- If extension folders exist, read each instructions.md first and follow it when interpreting
|
||||
that extension's memory source.
|
||||
|
||||
If the Phase 2 diff lists removed memory extension resources, use that extension-specific deletion
|
||||
diff to remove stale memories derived only from those resources.
|
||||
"#;
|
||||
|
||||
/// Builds the consolidation subagent prompt for a specific memory root.
|
||||
pub(super) fn build_consolidation_prompt(
|
||||
memory_root: &Path,
|
||||
selection: &Phase2InputSelection,
|
||||
removed_extension_resources: &[RemovedExtensionResource],
|
||||
) -> String {
|
||||
let memory_extensions_root = memory_extensions_root(memory_root);
|
||||
let memory_extensions_exist = memory_extensions_root.is_dir();
|
||||
@@ -100,7 +108,8 @@ pub(super) fn build_consolidation_prompt(
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let phase2_input_selection = render_phase2_input_selection(selection);
|
||||
let phase2_input_selection =
|
||||
render_phase2_input_selection(selection, removed_extension_resources);
|
||||
CONSOLIDATION_PROMPT_TEMPLATE
|
||||
.render([
|
||||
("memory_root", memory_root.as_str()),
|
||||
@@ -131,7 +140,10 @@ fn render_memory_extensions_block(template: &Template, memory_extensions_root: &
|
||||
})
|
||||
}
|
||||
|
||||
fn render_phase2_input_selection(selection: &Phase2InputSelection) -> String {
|
||||
fn render_phase2_input_selection(
|
||||
selection: &Phase2InputSelection,
|
||||
removed_extension_resources: &[RemovedExtensionResource],
|
||||
) -> String {
|
||||
let retained = selection.retained_thread_ids.len();
|
||||
let added = selection.selected.len().saturating_sub(retained);
|
||||
let selected = if selection.selected.is_empty() {
|
||||
@@ -160,11 +172,29 @@ fn render_phase2_input_selection(selection: &Phase2InputSelection) -> String {
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
format!(
|
||||
let mut rendered = format!(
|
||||
"- selected inputs this run: {}\n- newly added since the last successful Phase 2 run: {added}\n- retained from the last successful Phase 2 run: {retained}\n- removed from the last successful Phase 2 run: {}\n\nCurrent selected Phase 1 inputs:\n{selected}\n\nRemoved from the last successful Phase 2 selection:\n{removed}\n",
|
||||
selection.selected.len(),
|
||||
selection.removed.len(),
|
||||
)
|
||||
);
|
||||
|
||||
if !removed_extension_resources.is_empty() {
|
||||
rendered.push_str("\nMemory extension resources removed by retention pruning:\n");
|
||||
let _ = writeln!(
|
||||
rendered,
|
||||
"- retention window: {EXTENSION_RESOURCE_RETENTION_DAYS} days"
|
||||
);
|
||||
let mut current_extension = "";
|
||||
for removed_resource in removed_extension_resources {
|
||||
if removed_resource.extension != current_extension {
|
||||
current_extension = &removed_resource.extension;
|
||||
let _ = writeln!(rendered, "- extension: {current_extension}");
|
||||
}
|
||||
let _ = writeln!(rendered, " - {}", removed_resource.resource_path);
|
||||
}
|
||||
}
|
||||
|
||||
rendered
|
||||
}
|
||||
|
||||
fn render_selected_input_line(item: &Stage1Output, retained: bool) -> String {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
use crate::memories::extensions::RemovedExtensionResource;
|
||||
use codex_models_manager::model_info::model_info_from_slug;
|
||||
use codex_state::Phase2InputSelection;
|
||||
use core_test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
@@ -54,6 +56,36 @@ fn build_stage_one_input_message_uses_default_limit_when_model_context_window_mi
|
||||
assert!(message.contains(&expected_truncated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_consolidation_prompt_includes_removed_extension_resources() {
|
||||
let temp = tempdir().unwrap();
|
||||
let memory_root = temp.path().join("memories");
|
||||
std::fs::create_dir_all(temp.path().join("memories_extensions")).unwrap();
|
||||
let removed_extension_resources = vec![
|
||||
RemovedExtensionResource {
|
||||
extension: "telepathy".to_string(),
|
||||
resource_path: "resources/2026-04-06T11-59-59-abcd-10min-old.md".to_string(),
|
||||
},
|
||||
RemovedExtensionResource {
|
||||
extension: "telepathy".to_string(),
|
||||
resource_path: "resources/2026-04-07T12-00-00-abcd-10min-cutoff.md".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let prompt = build_consolidation_prompt(
|
||||
&memory_root,
|
||||
&Phase2InputSelection::default(),
|
||||
&removed_extension_resources,
|
||||
);
|
||||
|
||||
assert!(prompt.contains("Memory extension resources removed by retention pruning:"));
|
||||
assert!(prompt.contains("- retention window: 7 days"));
|
||||
assert!(prompt.contains("- extension: telepathy"));
|
||||
assert!(prompt.contains(" - resources/2026-04-06T11-59-59-abcd-10min-old.md"));
|
||||
assert!(prompt.contains(" - resources/2026-04-07T12-00-00-abcd-10min-cutoff.md"));
|
||||
assert!(prompt.contains("extension-specific deletion diff"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_memory_tool_developer_instructions_renders_embedded_template() {
|
||||
let temp = tempdir().unwrap();
|
||||
|
||||
@@ -424,6 +424,7 @@ mod phase2 {
|
||||
use crate::memories::phase2;
|
||||
use crate::memories::raw_memories_file;
|
||||
use crate::memories::rollout_summaries_dir;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Utc;
|
||||
use codex_config::Constrained;
|
||||
use codex_login::CodexAuth;
|
||||
@@ -960,6 +961,28 @@ mod phase2 {
|
||||
"stage-1 success should enqueue global consolidation"
|
||||
);
|
||||
|
||||
let telepathy_resources = config
|
||||
.codex_home
|
||||
.join("memories_extensions/telepathy/resources");
|
||||
tokio::fs::create_dir_all(&telepathy_resources)
|
||||
.await
|
||||
.expect("create telepathy resources");
|
||||
tokio::fs::write(
|
||||
config
|
||||
.codex_home
|
||||
.join("memories_extensions/telepathy/instructions.md"),
|
||||
"instructions",
|
||||
)
|
||||
.await
|
||||
.expect("write telepathy instructions");
|
||||
let old_file = telepathy_resources.join(format!(
|
||||
"{}-abcd-10min-old.md",
|
||||
(Utc::now() - ChronoDuration::days(8)).format("%Y-%m-%dT%H-%M-%S")
|
||||
));
|
||||
tokio::fs::write(&old_file, "old resource")
|
||||
.await
|
||||
.expect("write old extension resource");
|
||||
|
||||
phase2::run(&session, Arc::clone(&config)).await;
|
||||
|
||||
let retry_claim = state_db
|
||||
@@ -971,5 +994,11 @@ mod phase2 {
|
||||
Phase2JobClaimOutcome::SkippedNotDirty,
|
||||
"spawn failures should leave the job in retry backoff instead of running"
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&old_file)
|
||||
.await
|
||||
.expect("check old extension resource"),
|
||||
"spawn failures should not prune extension resources before retry"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,9 +275,15 @@ mod tests {
|
||||
);
|
||||
|
||||
let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?;
|
||||
assert!(config.contains("[marketplaces.debug]"));
|
||||
assert!(config.contains("source_type = \"local\""));
|
||||
assert!(config.contains(&format!("source = \"{expected_source}\"")));
|
||||
let config: toml::Value = toml::from_str(&config)?;
|
||||
assert_eq!(
|
||||
config["marketplaces"]["debug"]["source_type"].as_str(),
|
||||
Some("local")
|
||||
);
|
||||
assert_eq!(
|
||||
config["marketplaces"]["debug"]["source"].as_str(),
|
||||
Some(expected_source.as_str())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -296,19 +296,11 @@ mod tests {
|
||||
r#"{"name":"debug","plugins":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
format!(
|
||||
"[marketplaces.debug]\nsource_type = \"local\"\nsource = \"{}\"\n",
|
||||
source_root.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let source = MarketplaceSource::Local {
|
||||
path: source_root.clone(),
|
||||
};
|
||||
let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]);
|
||||
record_added_marketplace_entry(codex_home.path(), "debug", &install_metadata).unwrap();
|
||||
|
||||
let root = installed_marketplace_root_for_source(
|
||||
codex_home.path(),
|
||||
|
||||
@@ -124,9 +124,9 @@ fn normalize_git_url(url: &str) -> String {
|
||||
}
|
||||
|
||||
fn looks_like_local_path(source: &str) -> bool {
|
||||
source.starts_with("./")
|
||||
Path::new(source).is_absolute()
|
||||
|| source.starts_with("./")
|
||||
|| source.starts_with("../")
|
||||
|| source.starts_with('/')
|
||||
|| source.starts_with("~/")
|
||||
|| source == "."
|
||||
|| source == ".."
|
||||
|
||||
@@ -805,7 +805,9 @@ async fn handle_start_inner(
|
||||
if let Some(text) = maybe_routed_text {
|
||||
debug!(text = %text, "[realtime-text] realtime conversation text output");
|
||||
let sess_for_routed_text = Arc::clone(&sess_clone);
|
||||
sess_for_routed_text.route_realtime_text_input(text).await;
|
||||
sess_for_routed_text
|
||||
.route_realtime_text_input(wrap_realtime_delegation_input(&text))
|
||||
.await;
|
||||
}
|
||||
if !fanout_realtime_active.load(Ordering::Relaxed) {
|
||||
break;
|
||||
@@ -867,6 +869,20 @@ fn realtime_text_from_handoff_request(handoff: &RealtimeHandoffRequested) -> Opt
|
||||
.or((!handoff.input_transcript.is_empty()).then_some(handoff.input_transcript.clone()))
|
||||
}
|
||||
|
||||
fn wrap_realtime_delegation_input(input: &str) -> String {
|
||||
format!(
|
||||
"<realtime_delegation>\n <input>{}</input>\n</realtime_delegation>",
|
||||
escape_xml_text(input)
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_xml_text(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn realtime_api_key(auth: Option<&CodexAuth>, provider: &ModelProviderInfo) -> CodexResult<String> {
|
||||
if let Some(api_key) = provider.api_key()? {
|
||||
return Ok(api_key);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::RealtimeHandoffState;
|
||||
use super::RealtimeSessionKind;
|
||||
use super::realtime_text_from_handoff_request;
|
||||
use super::wrap_realtime_delegation_input;
|
||||
use async_channel::bounded;
|
||||
use codex_protocol::protocol::RealtimeHandoffRequested;
|
||||
use codex_protocol::protocol::RealtimeTranscriptEntry;
|
||||
@@ -54,6 +55,22 @@ fn ignores_empty_handoff_request_input_transcript() {
|
||||
assert_eq!(realtime_text_from_handoff_request(&handoff), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_realtime_delegation_input() {
|
||||
assert_eq!(
|
||||
wrap_realtime_delegation_input("hello"),
|
||||
"<realtime_delegation>\n <input>hello</input>\n</realtime_delegation>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_realtime_delegation_input_with_xml_escaping() {
|
||||
assert_eq!(
|
||||
wrap_realtime_delegation_input("use a < b && c > d"),
|
||||
"<realtime_delegation>\n <input>use a < b && c > d</input>\n</realtime_delegation>"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clears_active_handoff_explicitly() {
|
||||
let (tx, _rx) = bounded(1);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::SpawnChildRequest;
|
||||
use crate::spawn::StdioPolicy;
|
||||
use crate::spawn::spawn_child_async;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::process::Child;
|
||||
|
||||
pub async fn spawn_command_under_seatbelt(
|
||||
command: Vec<String>,
|
||||
command_cwd: AbsolutePathBuf,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_policy_cwd: &AbsolutePathBuf,
|
||||
stdio_policy: StdioPolicy,
|
||||
network: Option<&NetworkProxy>,
|
||||
mut env: HashMap<String, String>,
|
||||
) -> std::io::Result<Child> {
|
||||
let args = create_seatbelt_command_args_for_policies(
|
||||
command,
|
||||
&FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd),
|
||||
NetworkSandboxPolicy::from(sandbox_policy),
|
||||
sandbox_policy_cwd,
|
||||
/*enforce_managed_network*/ false,
|
||||
network,
|
||||
);
|
||||
let arg0 = None;
|
||||
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
spawn_child_async(SpawnChildRequest {
|
||||
program: PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE),
|
||||
args,
|
||||
arg0,
|
||||
cwd: command_cwd,
|
||||
network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy),
|
||||
network,
|
||||
stdio_policy,
|
||||
env,
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use crate::RolloutRecorder;
|
||||
use crate::SkillsManager;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::agent_identity::AgentIdentityManager;
|
||||
use crate::client::ModelClient;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
@@ -41,6 +42,7 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) hooks: Hooks,
|
||||
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
|
||||
pub(crate) user_shell: Arc<crate::shell::Shell>,
|
||||
pub(crate) agent_identity_manager: Arc<AgentIdentityManager>,
|
||||
pub(crate) shell_snapshot_tx: watch::Sender<Option<Arc<crate::shell_snapshot::ShellSnapshot>>>,
|
||||
pub(crate) show_raw_agent_reasoning: bool,
|
||||
pub(crate) exec_policy: Arc<ExecPolicyManager>,
|
||||
|
||||
@@ -116,7 +116,7 @@ struct CoreTurnHost {
|
||||
impl CodeModeTurnHost for CoreTurnHost {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
tool_name: String,
|
||||
tool_name: ToolName,
|
||||
input: Option<JsonValue>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
@@ -288,38 +288,39 @@ async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
|
||||
async fn call_nested_tool(
|
||||
exec: ExecContext,
|
||||
tool_runtime: ToolCallRuntime,
|
||||
tool_name: String,
|
||||
tool_name: ToolName,
|
||||
input: Option<JsonValue>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, FunctionCallError> {
|
||||
if tool_name == PUBLIC_TOOL_NAME {
|
||||
if tool_name.namespace.is_none() && tool_name.name == PUBLIC_TOOL_NAME {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{PUBLIC_TOOL_NAME} cannot invoke itself"
|
||||
)));
|
||||
}
|
||||
|
||||
let payload = if let Some(tool_info) = exec
|
||||
.session
|
||||
.resolve_mcp_tool_info(&tool_name, /*namespace*/ None)
|
||||
.await
|
||||
{
|
||||
match serialize_function_tool_arguments(&tool_name, input) {
|
||||
Ok(raw_arguments) => ToolPayload::Mcp {
|
||||
server: tool_info.server_name,
|
||||
tool: tool_info.tool.name.to_string(),
|
||||
raw_arguments,
|
||||
},
|
||||
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
|
||||
}
|
||||
} else {
|
||||
match build_nested_tool_payload(tool_runtime.find_spec(&tool_name), &tool_name, input) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
|
||||
}
|
||||
};
|
||||
let (tool_call_name, payload) =
|
||||
if let Some(tool_info) = exec.session.resolve_mcp_tool_info(&tool_name).await {
|
||||
let raw_arguments = match serialize_function_tool_arguments(&tool_name, input) {
|
||||
Ok(raw_arguments) => raw_arguments,
|
||||
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
|
||||
};
|
||||
(
|
||||
tool_info.canonical_tool_name(),
|
||||
ToolPayload::Mcp {
|
||||
server: tool_info.server_name,
|
||||
tool: tool_info.tool.name.to_string(),
|
||||
raw_arguments,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
match build_nested_tool_payload(tool_runtime.find_spec(&tool_name), &tool_name, input) {
|
||||
Ok(payload) => (tool_name, payload),
|
||||
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
|
||||
}
|
||||
};
|
||||
|
||||
let call = ToolCall {
|
||||
tool_name: ToolName::plain(tool_name.clone()),
|
||||
tool_name: tool_call_name,
|
||||
call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()),
|
||||
payload,
|
||||
};
|
||||
@@ -339,7 +340,7 @@ fn tool_kind_for_spec(spec: &ToolSpec) -> codex_code_mode::CodeModeToolKind {
|
||||
|
||||
fn tool_kind_for_name(
|
||||
spec: Option<ToolSpec>,
|
||||
tool_name: &str,
|
||||
tool_name: &ToolName,
|
||||
) -> Result<codex_code_mode::CodeModeToolKind, String> {
|
||||
spec.as_ref()
|
||||
.map(tool_kind_for_spec)
|
||||
@@ -348,7 +349,7 @@ fn tool_kind_for_name(
|
||||
|
||||
fn build_nested_tool_payload(
|
||||
spec: Option<ToolSpec>,
|
||||
tool_name: &str,
|
||||
tool_name: &ToolName,
|
||||
input: Option<JsonValue>,
|
||||
) -> Result<ToolPayload, String> {
|
||||
let actual_kind = tool_kind_for_name(spec, tool_name)?;
|
||||
@@ -363,7 +364,7 @@ fn build_nested_tool_payload(
|
||||
}
|
||||
|
||||
fn build_function_tool_payload(
|
||||
tool_name: &str,
|
||||
tool_name: &ToolName,
|
||||
input: Option<JsonValue>,
|
||||
) -> Result<ToolPayload, String> {
|
||||
let arguments = serialize_function_tool_arguments(tool_name, input)?;
|
||||
@@ -371,7 +372,7 @@ fn build_function_tool_payload(
|
||||
}
|
||||
|
||||
fn serialize_function_tool_arguments(
|
||||
tool_name: &str,
|
||||
tool_name: &ToolName,
|
||||
input: Option<JsonValue>,
|
||||
) -> Result<String, String> {
|
||||
match input {
|
||||
@@ -385,7 +386,7 @@ fn serialize_function_tool_arguments(
|
||||
}
|
||||
|
||||
fn build_freeform_tool_payload(
|
||||
tool_name: &str,
|
||||
tool_name: &ToolName,
|
||||
input: Option<JsonValue>,
|
||||
) -> Result<ToolPayload, String> {
|
||||
match input {
|
||||
|
||||
@@ -1493,24 +1493,25 @@ async fn multi_agent_v2_followup_task_interrupts_busy_child_without_losing_messa
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
|
||||
SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "boot worker",
|
||||
"task_name": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn worker");
|
||||
let worker_path = AgentPath::try_from("/root/worker").expect("worker path");
|
||||
let agent_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.spawn_agent_with_metadata(
|
||||
(*turn.config).clone(),
|
||||
Op::CleanBackgroundTerminals,
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(worker_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
crate::agent::control::SpawnAgentOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("worker should resolve");
|
||||
.expect("worker spawn should succeed")
|
||||
.thread_id;
|
||||
let thread = manager
|
||||
.get_thread(agent_id)
|
||||
.await
|
||||
@@ -1567,7 +1568,7 @@ async fn multi_agent_v2_followup_task_interrupts_busy_child_without_losing_messa
|
||||
&thread,
|
||||
&InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
worker_path,
|
||||
Vec::new(),
|
||||
"continue".to_string(),
|
||||
/*trigger_turn*/ true,
|
||||
|
||||
@@ -45,6 +45,8 @@ use codex_sandboxing::SandboxCommand;
|
||||
use codex_sandboxing::SandboxManager;
|
||||
use codex_sandboxing::SandboxTransformRequest;
|
||||
use codex_sandboxing::SandboxablePreference;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
@@ -1574,29 +1576,67 @@ impl JsReplManager {
|
||||
},
|
||||
);
|
||||
|
||||
let payload = if let Some(tool_info) = exec
|
||||
let specs = router.specs();
|
||||
let requested_tool_name = specs
|
||||
.iter()
|
||||
.find_map(|spec| match spec {
|
||||
ToolSpec::Function(tool) if tool.name == req.tool_name => {
|
||||
Some(ToolName::plain(req.tool_name.clone()))
|
||||
}
|
||||
ToolSpec::Freeform(tool) if tool.name == req.tool_name => {
|
||||
Some(ToolName::plain(req.tool_name.clone()))
|
||||
}
|
||||
ToolSpec::Namespace(namespace) => {
|
||||
namespace.tools.iter().find_map(|tool| match tool {
|
||||
ResponsesApiNamespaceTool::Function(tool) => {
|
||||
let tool_name =
|
||||
ToolName::namespaced(namespace.name.clone(), tool.name.clone());
|
||||
(tool_name.display() == req.tool_name).then_some(tool_name)
|
||||
}
|
||||
})
|
||||
}
|
||||
ToolSpec::LocalShell {}
|
||||
| ToolSpec::ImageGeneration { .. }
|
||||
| ToolSpec::ToolSearch { .. }
|
||||
| ToolSpec::WebSearch { .. }
|
||||
| ToolSpec::Function(_)
|
||||
| ToolSpec::Freeform(_) => None,
|
||||
})
|
||||
.unwrap_or_else(|| ToolName::plain(req.tool_name.clone()));
|
||||
let (tool_call_name, payload) = if let Some(tool_info) = exec
|
||||
.session
|
||||
.resolve_mcp_tool_info(&req.tool_name, /*namespace*/ None)
|
||||
.resolve_mcp_tool_info(&requested_tool_name)
|
||||
.await
|
||||
{
|
||||
crate::tools::context::ToolPayload::Mcp {
|
||||
server: tool_info.server_name,
|
||||
tool: tool_info.tool.name.to_string(),
|
||||
raw_arguments: req.arguments.clone(),
|
||||
}
|
||||
} else if is_freeform_tool(&router.specs(), &req.tool_name) {
|
||||
crate::tools::context::ToolPayload::Custom {
|
||||
input: req.arguments.clone(),
|
||||
}
|
||||
(
|
||||
tool_info.canonical_tool_name(),
|
||||
crate::tools::context::ToolPayload::Mcp {
|
||||
server: tool_info.server_name,
|
||||
tool: tool_info.tool.name.to_string(),
|
||||
raw_arguments: req.arguments.clone(),
|
||||
},
|
||||
)
|
||||
} else if matches!(
|
||||
router.find_spec(&requested_tool_name),
|
||||
Some(ToolSpec::Freeform(_))
|
||||
) {
|
||||
(
|
||||
requested_tool_name,
|
||||
crate::tools::context::ToolPayload::Custom {
|
||||
input: req.arguments.clone(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
crate::tools::context::ToolPayload::Function {
|
||||
arguments: req.arguments.clone(),
|
||||
}
|
||||
(
|
||||
requested_tool_name,
|
||||
crate::tools::context::ToolPayload::Function {
|
||||
arguments: req.arguments.clone(),
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
let tool_name = req.tool_name.clone();
|
||||
let call = crate::tools::router::ToolCall {
|
||||
tool_name: codex_tools::ToolName::plain(tool_name.clone()),
|
||||
tool_name: tool_call_name,
|
||||
call_id: req.id.clone(),
|
||||
payload,
|
||||
};
|
||||
@@ -1755,12 +1795,6 @@ fn split_exec_result_content_items(
|
||||
}
|
||||
}
|
||||
|
||||
fn is_freeform_tool(specs: &[ToolSpec], name: &str) -> bool {
|
||||
specs
|
||||
.iter()
|
||||
.any(|spec| spec.name() == name && matches!(spec, ToolSpec::Freeform(_)))
|
||||
}
|
||||
|
||||
fn is_js_repl_internal_tool(name: &str) -> bool {
|
||||
matches!(name, "js_repl" | "js_repl_reset")
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ impl ToolCallRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_spec(&self, tool_name: &str) -> Option<ToolSpec> {
|
||||
pub(crate) fn find_spec(&self, tool_name: &codex_tools::ToolName) -> Option<ToolSpec> {
|
||||
self.router.find_spec(tool_name)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_protocol::models::SearchToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_tools::ConfiguredToolSpec;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::ToolsConfig;
|
||||
@@ -102,20 +103,48 @@ impl ToolRouter {
|
||||
self.model_visible_specs.clone()
|
||||
}
|
||||
|
||||
pub fn find_spec(&self, tool_name: &str) -> Option<ToolSpec> {
|
||||
self.specs
|
||||
.iter()
|
||||
.find(|config| config.name() == tool_name)
|
||||
.map(|config| config.spec.clone())
|
||||
pub fn find_spec(&self, tool_name: &ToolName) -> Option<ToolSpec> {
|
||||
self.specs.iter().find_map(|config| match &config.spec {
|
||||
ToolSpec::Function(tool)
|
||||
if tool_name.namespace.is_none() && tool.name == tool_name.name =>
|
||||
{
|
||||
Some(config.spec.clone())
|
||||
}
|
||||
ToolSpec::Freeform(tool)
|
||||
if tool_name.namespace.is_none() && tool.name == tool_name.name =>
|
||||
{
|
||||
Some(config.spec.clone())
|
||||
}
|
||||
ToolSpec::Namespace(namespace) => namespace.tools.iter().find_map(|tool| match tool {
|
||||
ResponsesApiNamespaceTool::Function(tool)
|
||||
if tool_name.namespace.as_deref() == Some(namespace.name.as_str())
|
||||
&& tool.name == tool_name.name =>
|
||||
{
|
||||
Some(ToolSpec::Function(tool.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn configured_tool_supports_parallel(&self, tool_name: &ToolName) -> bool {
|
||||
tool_name.namespace.is_none()
|
||||
&& self
|
||||
.specs
|
||||
.iter()
|
||||
.filter(|config| config.supports_parallel_tool_calls)
|
||||
.any(|config| config.name() == tool_name.name.as_str())
|
||||
if tool_name.namespace.is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.specs
|
||||
.iter()
|
||||
.filter(|config| config.supports_parallel_tool_calls)
|
||||
.any(|config| match &config.spec {
|
||||
ToolSpec::Function(tool) => tool.name == tool_name.name.as_str(),
|
||||
ToolSpec::Freeform(tool) => tool.name == tool_name.name.as_str(),
|
||||
ToolSpec::Namespace(_)
|
||||
| ToolSpec::ToolSearch { .. }
|
||||
| ToolSpec::LocalShell {}
|
||||
| ToolSpec::ImageGeneration { .. }
|
||||
| ToolSpec::WebSearch { .. } => false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_supports_parallel(&self, call: &ToolCall) -> bool {
|
||||
@@ -141,16 +170,10 @@ impl ToolRouter {
|
||||
call_id,
|
||||
..
|
||||
} => {
|
||||
let mcp_tool = session
|
||||
.resolve_mcp_tool_info(&name, namespace.as_deref())
|
||||
.await;
|
||||
let tool_name = match namespace {
|
||||
Some(namespace) => ToolName::namespaced(namespace, name),
|
||||
None => ToolName::plain(name),
|
||||
};
|
||||
if let Some(tool_info) = mcp_tool {
|
||||
let tool_name = ToolName::new(namespace, name);
|
||||
if let Some(tool_info) = session.resolve_mcp_tool_info(&tool_name).await {
|
||||
Ok(Some(ToolCall {
|
||||
tool_name,
|
||||
tool_name: tool_info.canonical_tool_name(),
|
||||
call_id,
|
||||
payload: ToolPayload::Mcp {
|
||||
server: tool_info.server_name,
|
||||
|
||||
@@ -11,6 +11,7 @@ use codex_tools::DiscoverableTool;
|
||||
use codex_tools::ToolHandlerKind;
|
||||
use codex_tools::ToolNamespace;
|
||||
use codex_tools::ToolRegistryPlanDeferredTool;
|
||||
use codex_tools::ToolRegistryPlanMcpTool;
|
||||
use codex_tools::ToolRegistryPlanParams;
|
||||
use codex_tools::ToolUserShellType;
|
||||
use codex_tools::ToolsConfig;
|
||||
@@ -29,25 +30,31 @@ pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType {
|
||||
}
|
||||
}
|
||||
|
||||
struct McpToolPlanInputs {
|
||||
mcp_tools: HashMap<String, rmcp::model::Tool>,
|
||||
struct McpToolPlanInputs<'a> {
|
||||
mcp_tools: Vec<ToolRegistryPlanMcpTool<'a>>,
|
||||
tool_namespaces: HashMap<String, ToolNamespace>,
|
||||
}
|
||||
|
||||
fn map_mcp_tools_for_plan(mcp_tools: &HashMap<String, ToolInfo>) -> McpToolPlanInputs {
|
||||
fn map_mcp_tools_for_plan(mcp_tools: &HashMap<String, ToolInfo>) -> McpToolPlanInputs<'_> {
|
||||
McpToolPlanInputs {
|
||||
mcp_tools: mcp_tools
|
||||
.iter()
|
||||
.map(|(name, tool)| (name.clone(), tool.tool.clone()))
|
||||
.values()
|
||||
.map(|tool| ToolRegistryPlanMcpTool {
|
||||
name: tool.canonical_tool_name(),
|
||||
tool: &tool.tool,
|
||||
})
|
||||
.collect(),
|
||||
tool_namespaces: mcp_tools
|
||||
.iter()
|
||||
.map(|(name, tool)| {
|
||||
.values()
|
||||
.map(|tool| {
|
||||
(
|
||||
name.clone(),
|
||||
tool.callable_namespace.clone(),
|
||||
ToolNamespace {
|
||||
name: tool.callable_namespace.clone(),
|
||||
description: tool.server_instructions.clone(),
|
||||
description: tool
|
||||
.connector_description
|
||||
.clone()
|
||||
.or_else(|| tool.server_instructions.clone()),
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -99,8 +106,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
tools
|
||||
.values()
|
||||
.map(|tool| ToolRegistryPlanDeferredTool {
|
||||
tool_name: tool.callable_name.as_str(),
|
||||
tool_namespace: tool.callable_namespace.as_str(),
|
||||
name: tool.canonical_tool_name(),
|
||||
server_name: tool.server_name.as_str(),
|
||||
connector_name: tool.connector_name.as_deref(),
|
||||
connector_description: tool.connector_description.as_deref(),
|
||||
@@ -114,7 +120,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
ToolRegistryPlanParams {
|
||||
mcp_tools: mcp_tool_plan_inputs
|
||||
.as_ref()
|
||||
.map(|inputs| &inputs.mcp_tools),
|
||||
.map(|inputs| inputs.mcp_tools.as_slice()),
|
||||
deferred_mcp_tools: deferred_mcp_tool_sources.as_deref(),
|
||||
tool_namespaces: mcp_tool_plan_inputs
|
||||
.as_ref()
|
||||
|
||||
@@ -19,6 +19,7 @@ use codex_protocol::protocol::SessionSource;
|
||||
use codex_tools::ConfiguredToolSpec;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::JsonSchema;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ShellCommandBackendConfig;
|
||||
use codex_tools::TOOL_SEARCH_TOOL_NAME;
|
||||
@@ -67,6 +68,25 @@ fn mcp_tool_info(tool: rmcp::model::Tool) -> ToolInfo {
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_tool_info_with_display_name(display_name: &str, tool: rmcp::model::Tool) -> ToolInfo {
|
||||
let (callable_namespace, callable_name) = display_name
|
||||
.rsplit_once('/')
|
||||
.map(|(namespace, callable_name)| (format!("{namespace}/"), callable_name.to_string()))
|
||||
.unwrap_or_else(|| ("".to_string(), display_name.to_string()));
|
||||
|
||||
ToolInfo {
|
||||
server_name: "test_server".to_string(),
|
||||
callable_name,
|
||||
callable_namespace,
|
||||
server_instructions: None,
|
||||
tool,
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
connector_description: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool {
|
||||
let slug = name.replace(' ', "-").to_lowercase();
|
||||
DiscoverableTool::Connector(Box::new(AppInfo {
|
||||
@@ -109,8 +129,11 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() {
|
||||
);
|
||||
|
||||
let serialized = serde_json::to_value(ToolSpec::Function(
|
||||
mcp_tool_to_deferred_responses_api_tool("mcp__codex_apps__lookup_order".to_string(), &tool)
|
||||
.expect("convert deferred tool"),
|
||||
mcp_tool_to_deferred_responses_api_tool(
|
||||
&ToolName::namespaced("mcp__codex_apps__", "lookup_order"),
|
||||
&tool,
|
||||
)
|
||||
.expect("convert deferred tool"),
|
||||
))
|
||||
.expect("serialize deferred tool");
|
||||
|
||||
@@ -118,7 +141,7 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() {
|
||||
serialized,
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"name": "mcp__codex_apps__lookup_order",
|
||||
"name": "lookup_order",
|
||||
"description": "Look up an order",
|
||||
"strict": false,
|
||||
"defer_loading": true,
|
||||
@@ -173,6 +196,25 @@ fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a Co
|
||||
.unwrap_or_else(|| panic!("expected tool {expected_name}"))
|
||||
}
|
||||
|
||||
fn find_namespace_function_tool<'a>(
|
||||
tools: &'a [ConfiguredToolSpec],
|
||||
expected_namespace: &str,
|
||||
expected_name: &str,
|
||||
) -> &'a ResponsesApiTool {
|
||||
let namespace_tool = find_tool(tools, expected_namespace);
|
||||
let ToolSpec::Namespace(namespace) = &namespace_tool.spec else {
|
||||
panic!("expected namespace tool {expected_namespace}");
|
||||
};
|
||||
namespace
|
||||
.tools
|
||||
.iter()
|
||||
.find_map(|tool| match tool {
|
||||
ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name => Some(tool),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("expected tool {expected_namespace}{expected_name} in namespace"))
|
||||
}
|
||||
|
||||
fn multi_agent_v2_tools_config() -> ToolsConfig {
|
||||
let config = test_config();
|
||||
let model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
@@ -910,8 +952,43 @@ fn search_tool_registers_namespaced_mcp_tool_aliases() {
|
||||
assert!(registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME)));
|
||||
assert!(registry.has_handler(&app_alias));
|
||||
assert!(registry.has_handler(&mcp_alias));
|
||||
assert!(registry.has_handler(&ToolName::plain("mcp__codex_apps__calendar_create_event")));
|
||||
assert!(registry.has_handler(&ToolName::plain("mcp__rmcp__echo")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_mcp_tools_register_namespaced_handlers() {
|
||||
let config = test_config();
|
||||
let model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
|
||||
let (_, registry) = build_specs(
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"mcp__test_server__echo".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"echo",
|
||||
"Echo",
|
||||
serde_json::json!({"type": "object"}),
|
||||
)),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
assert!(registry.has_handler(&ToolName::namespaced("mcp__test_server__", "echo")));
|
||||
assert!(!registry.has_handler(&ToolName::plain("mcp__test_server__echo")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -936,27 +1013,30 @@ fn test_mcp_tool_property_missing_type_defaults_to_string() {
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"dash/search".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"search",
|
||||
"Search docs",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"description": "search query"}
|
||||
}
|
||||
}),
|
||||
)),
|
||||
mcp_tool_info_with_display_name(
|
||||
"dash/search",
|
||||
mcp_tool(
|
||||
"search",
|
||||
"Search docs",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"description": "search query"}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool = find_tool(&tools, "dash/search");
|
||||
let tool = find_namespace_function_tool(&tools, "dash/", "search");
|
||||
assert_eq!(
|
||||
tool.spec,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "dash/search".to_string(),
|
||||
*tool,
|
||||
ResponsesApiTool {
|
||||
name: "search".to_string(),
|
||||
parameters: JsonSchema::object(
|
||||
/*properties*/
|
||||
BTreeMap::from([(
|
||||
@@ -970,7 +1050,7 @@ fn test_mcp_tool_property_missing_type_defaults_to_string() {
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -996,25 +1076,28 @@ fn test_mcp_tool_preserves_integer_schema() {
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"dash/paginate".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"paginate",
|
||||
"Pagination",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {"page": {"type": "integer"}}
|
||||
}),
|
||||
)),
|
||||
mcp_tool_info_with_display_name(
|
||||
"dash/paginate",
|
||||
mcp_tool(
|
||||
"paginate",
|
||||
"Pagination",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {"page": {"type": "integer"}}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool = find_tool(&tools, "dash/paginate");
|
||||
let tool = find_namespace_function_tool(&tools, "dash/", "paginate");
|
||||
assert_eq!(
|
||||
tool.spec,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "dash/paginate".to_string(),
|
||||
*tool,
|
||||
ResponsesApiTool {
|
||||
name: "paginate".to_string(),
|
||||
parameters: JsonSchema::object(
|
||||
/*properties*/
|
||||
BTreeMap::from([(
|
||||
@@ -1028,7 +1111,7 @@ fn test_mcp_tool_preserves_integer_schema() {
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1055,25 +1138,28 @@ fn test_mcp_tool_array_without_items_gets_default_string_items() {
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"dash/tags".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"tags",
|
||||
"Tags",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {"tags": {"type": "array"}}
|
||||
}),
|
||||
)),
|
||||
mcp_tool_info_with_display_name(
|
||||
"dash/tags",
|
||||
mcp_tool(
|
||||
"tags",
|
||||
"Tags",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {"tags": {"type": "array"}}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool = find_tool(&tools, "dash/tags");
|
||||
let tool = find_namespace_function_tool(&tools, "dash/", "tags");
|
||||
assert_eq!(
|
||||
tool.spec,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "dash/tags".to_string(),
|
||||
*tool,
|
||||
ResponsesApiTool {
|
||||
name: "tags".to_string(),
|
||||
parameters: JsonSchema::object(
|
||||
/*properties*/
|
||||
BTreeMap::from([(
|
||||
@@ -1090,7 +1176,7 @@ fn test_mcp_tool_array_without_items_gets_default_string_items() {
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1116,27 +1202,30 @@ fn test_mcp_tool_anyof_defaults_to_string() {
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"dash/value".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"value",
|
||||
"AnyOf Value",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {"anyOf": [{"type": "string"}, {"type": "number"}]}
|
||||
}
|
||||
}),
|
||||
)),
|
||||
mcp_tool_info_with_display_name(
|
||||
"dash/value",
|
||||
mcp_tool(
|
||||
"value",
|
||||
"AnyOf Value",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {"anyOf": [{"type": "string"}, {"type": "number"}]}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool = find_tool(&tools, "dash/value");
|
||||
let tool = find_namespace_function_tool(&tools, "dash/", "value");
|
||||
assert_eq!(
|
||||
tool.spec,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "dash/value".to_string(),
|
||||
*tool,
|
||||
ResponsesApiTool {
|
||||
name: "value".to_string(),
|
||||
parameters: JsonSchema::object(
|
||||
/*properties*/
|
||||
BTreeMap::from([(
|
||||
@@ -1156,7 +1245,7 @@ fn test_mcp_tool_anyof_defaults_to_string() {
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1181,12 +1270,14 @@ fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"test_server/do_something_cool".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"do_something_cool",
|
||||
"Do something cool",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
mcp_tool_info_with_display_name(
|
||||
"test_server/do_something_cool",
|
||||
mcp_tool(
|
||||
"do_something_cool",
|
||||
"Do something cool",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"string_argument": {"type": "string"},
|
||||
"number_argument": {"type": "number"},
|
||||
"object_argument": {
|
||||
@@ -1203,22 +1294,23 @@ fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
|
||||
},
|
||||
"required": ["addtl_prop"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool = find_tool(&tools, "test_server/do_something_cool");
|
||||
let tool = find_namespace_function_tool(&tools, "test_server/", "do_something_cool");
|
||||
assert_eq!(
|
||||
tool.spec,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "test_server/do_something_cool".to_string(),
|
||||
*tool,
|
||||
ResponsesApiTool {
|
||||
name: "do_something_cool".to_string(),
|
||||
parameters: JsonSchema::object(
|
||||
/*properties*/
|
||||
BTreeMap::from([
|
||||
@@ -1268,7 +1360,7 @@ fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ impl ResponsesRequest {
|
||||
self.body_json().to_string().contains(&json_fragment)
|
||||
}
|
||||
|
||||
pub fn tool_by_name(&self, namespace: &str, tool_name: &str) -> Option<Value> {
|
||||
namespace_child_tool(&self.body_json(), namespace, tool_name).cloned()
|
||||
}
|
||||
|
||||
pub fn instructions_text(&self) -> String {
|
||||
self.body_json()["instructions"]
|
||||
.as_str()
|
||||
@@ -315,6 +319,31 @@ pub(crate) fn output_value_to_text(value: &Value) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn namespace_child_tool<'a>(
|
||||
body: &'a Value,
|
||||
namespace: &str,
|
||||
tool_name: &str,
|
||||
) -> Option<&'a Value> {
|
||||
let tools = body.get("tools")?.as_array()?;
|
||||
for tool in tools {
|
||||
if tool.get("name").and_then(Value::as_str) != Some(namespace)
|
||||
|| tool.get("type").and_then(Value::as_str) != Some("namespace")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let child_tools = tool.get("tools")?.as_array()?;
|
||||
if let Some(child_tool) = child_tools
|
||||
.iter()
|
||||
.find(|tool| tool.get("name").and_then(Value::as_str) == Some(tool_name))
|
||||
{
|
||||
return Some(child_tool);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -780,6 +809,24 @@ pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_function_call_with_namespace(
|
||||
call_id: &str,
|
||||
namespace: &str,
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"call_id": call_id,
|
||||
"namespace": namespace,
|
||||
"name": name,
|
||||
"arguments": arguments
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_tool_search_call(call_id: &str, arguments: &serde_json::Value) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.output_item.done",
|
||||
|
||||
@@ -7,8 +7,6 @@ use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
@@ -44,7 +42,6 @@ use tempfile::TempDir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
use crate::PathBufExt;
|
||||
use crate::RemoteEnvConfig;
|
||||
use crate::TempDirExt;
|
||||
use crate::get_remote_test_env;
|
||||
use crate::load_default_config_for_test;
|
||||
@@ -62,50 +59,15 @@ type PreBuildHook = dyn FnOnce(&Path) + Send + 'static;
|
||||
type WorkspaceSetup = dyn FnOnce(AbsolutePathBuf, Arc<dyn ExecutorFileSystem>) -> BoxFuture<'static, Result<()>>
|
||||
+ Send;
|
||||
const TEST_MODEL_WITH_EXPERIMENTAL_TOOLS: &str = "test-gpt-5.1-codex";
|
||||
const REMOTE_EXEC_SERVER_START_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const REMOTE_EXEC_SERVER_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
static REMOTE_EXEC_SERVER_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RemoteExecServerProcess {
|
||||
container_name: String,
|
||||
pid: u32,
|
||||
remote_exec_server_path: String,
|
||||
stdout_path: String,
|
||||
cleanup_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl Drop for RemoteExecServerProcess {
|
||||
fn drop(&mut self) {
|
||||
let cleanup_paths = self.cleanup_paths.join(" ");
|
||||
let cleanup_paths_script = if cleanup_paths.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("rm -rf {cleanup_paths}; ")
|
||||
};
|
||||
let script = format!(
|
||||
"if kill -0 {pid} 2>/dev/null; then kill {pid}; fi; {cleanup_paths_script}rm -f {remote_exec_server_path} {stdout_path}",
|
||||
pid = self.pid,
|
||||
cleanup_paths_script = cleanup_paths_script,
|
||||
remote_exec_server_path = self.remote_exec_server_path,
|
||||
stdout_path = self.stdout_path
|
||||
);
|
||||
let _ = docker_command_capture_stdout(["exec", &self.container_name, "sh", "-lc", &script]);
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteExecServerProcess {
|
||||
fn register_cleanup_path(&mut self, path: &Path) {
|
||||
self.cleanup_paths.push(path.display().to_string());
|
||||
}
|
||||
}
|
||||
const REMOTE_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_TEST_REMOTE_EXEC_SERVER_URL";
|
||||
static REMOTE_TEST_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TestEnv {
|
||||
environment: codex_exec_server::Environment,
|
||||
cwd: AbsolutePathBuf,
|
||||
local_cwd_temp_dir: Option<Arc<TempDir>>,
|
||||
_remote_exec_server_process: Option<RemoteExecServerProcess>,
|
||||
remote_container_name: Option<String>,
|
||||
}
|
||||
|
||||
impl TestEnv {
|
||||
@@ -117,7 +79,7 @@ impl TestEnv {
|
||||
environment,
|
||||
cwd,
|
||||
local_cwd_temp_dir: Some(local_cwd_temp_dir),
|
||||
_remote_exec_server_process: None,
|
||||
remote_container_name: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,12 +100,19 @@ impl TestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestEnv {
|
||||
fn drop(&mut self) {
|
||||
if let Some(container_name) = &self.remote_container_name {
|
||||
let script = format!("rm -rf {}", self.cwd.as_path().display());
|
||||
let _ = docker_command_capture_stdout(["exec", container_name, "sh", "-lc", &script]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn test_env() -> Result<TestEnv> {
|
||||
match get_remote_test_env() {
|
||||
Some(remote_env) => {
|
||||
let mut remote_process = start_remote_exec_server(&remote_env)?;
|
||||
let remote_ip = remote_container_ip(&remote_env.container_name)?;
|
||||
let websocket_url = rewrite_websocket_host(&remote_process.listen_url, &remote_ip)?;
|
||||
let websocket_url = remote_exec_server_url()?;
|
||||
let environment = codex_exec_server::Environment::create(Some(websocket_url)).await?;
|
||||
let cwd = remote_aware_cwd_path();
|
||||
environment
|
||||
@@ -154,184 +123,43 @@ pub async fn test_env() -> Result<TestEnv> {
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
remote_process.process.register_cleanup_path(cwd.as_path());
|
||||
Ok(TestEnv {
|
||||
environment,
|
||||
cwd,
|
||||
local_cwd_temp_dir: None,
|
||||
_remote_exec_server_process: Some(remote_process.process),
|
||||
remote_container_name: Some(remote_env.container_name),
|
||||
})
|
||||
}
|
||||
None => TestEnv::local().await,
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteExecServerStart {
|
||||
process: RemoteExecServerProcess,
|
||||
listen_url: String,
|
||||
}
|
||||
|
||||
fn start_remote_exec_server(remote_env: &RemoteEnvConfig) -> Result<RemoteExecServerStart> {
|
||||
let container_name = remote_env.container_name.as_str();
|
||||
let instance_id = remote_exec_server_instance_id();
|
||||
let remote_exec_server_path = format!("/tmp/codex-{instance_id}");
|
||||
let remote_linux_sandbox_path = format!("/tmp/codex-linux-sandbox-{instance_id}");
|
||||
let stdout_path = format!("/tmp/codex-exec-server-{instance_id}.stdout");
|
||||
let local_binary = codex_utils_cargo_bin::cargo_bin("codex").context("resolve codex binary")?;
|
||||
let local_linux_sandbox = codex_utils_cargo_bin::cargo_bin("codex-linux-sandbox")
|
||||
.context("resolve codex-linux-sandbox binary")?;
|
||||
let local_binary = local_binary.to_string_lossy().to_string();
|
||||
let local_linux_sandbox = local_linux_sandbox.to_string_lossy().to_string();
|
||||
let remote_binary = format!("{container_name}:{remote_exec_server_path}");
|
||||
let remote_linux_sandbox = format!("{container_name}:{remote_linux_sandbox_path}");
|
||||
|
||||
docker_command_success(["cp", &local_binary, &remote_binary])?;
|
||||
docker_command_success(["cp", &local_linux_sandbox, &remote_linux_sandbox])?;
|
||||
docker_command_success([
|
||||
"exec",
|
||||
container_name,
|
||||
"chmod",
|
||||
"+x",
|
||||
&remote_exec_server_path,
|
||||
])?;
|
||||
docker_command_success([
|
||||
"exec",
|
||||
container_name,
|
||||
"chmod",
|
||||
"+x",
|
||||
&remote_linux_sandbox_path,
|
||||
])?;
|
||||
probe_remote_linux_sandbox(container_name, &remote_linux_sandbox_path)?;
|
||||
|
||||
let start_script = format!(
|
||||
"rm -f {stdout_path}; \
|
||||
nohup {remote_exec_server_path} exec-server --listen ws://0.0.0.0:0 > {stdout_path} 2>&1 & \
|
||||
echo $!"
|
||||
);
|
||||
let pid_output =
|
||||
docker_command_capture_stdout(["exec", container_name, "sh", "-lc", &start_script])?;
|
||||
let pid = pid_output
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.with_context(|| format!("parse remote exec-server PID from {pid_output:?}"))?;
|
||||
|
||||
let listen_url = wait_for_remote_listen_url(container_name, &stdout_path)?;
|
||||
|
||||
Ok(RemoteExecServerStart {
|
||||
process: RemoteExecServerProcess {
|
||||
container_name: container_name.to_string(),
|
||||
pid,
|
||||
remote_exec_server_path,
|
||||
stdout_path,
|
||||
cleanup_paths: vec![remote_linux_sandbox_path],
|
||||
},
|
||||
listen_url,
|
||||
})
|
||||
}
|
||||
|
||||
fn probe_remote_linux_sandbox(container_name: &str, remote_linux_sandbox_path: &str) -> Result<()> {
|
||||
let policy = serde_json::to_string(&SandboxPolicy::new_read_only_policy())
|
||||
.context("serialize remote sandbox probe policy")?;
|
||||
let probe_script = format!(
|
||||
"{remote_linux_sandbox_path} --sandbox-policy-cwd /tmp --sandbox-policy '{policy}' -- /bin/true"
|
||||
);
|
||||
let output = Command::new("docker")
|
||||
.args(["exec", container_name, "sh", "-lc", &probe_script])
|
||||
.output()
|
||||
.with_context(|| format!("probe remote linux sandbox in container `{container_name}`"))?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"remote linux sandbox probe failed in container `{container_name}`: stdout={} stderr={}",
|
||||
String::from_utf8_lossy(&output.stdout).trim(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remote_aware_cwd_path() -> AbsolutePathBuf {
|
||||
PathBuf::from(format!(
|
||||
"/tmp/codex-core-test-cwd-{}",
|
||||
remote_exec_server_instance_id()
|
||||
remote_test_instance_id()
|
||||
))
|
||||
.abs()
|
||||
}
|
||||
|
||||
fn wait_for_remote_listen_url(container_name: &str, stdout_path: &str) -> Result<String> {
|
||||
let deadline = Instant::now() + REMOTE_EXEC_SERVER_START_TIMEOUT;
|
||||
loop {
|
||||
let line = docker_command_capture_stdout([
|
||||
"exec",
|
||||
container_name,
|
||||
"sh",
|
||||
"-lc",
|
||||
&format!("head -n 1 {stdout_path} 2>/dev/null || true"),
|
||||
])?;
|
||||
let listen_url = line.trim();
|
||||
if listen_url.starts_with("ws://") {
|
||||
return Ok(listen_url.to_string());
|
||||
}
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"timed out waiting for remote exec-server listen URL in container `{container_name}` after {REMOTE_EXEC_SERVER_START_TIMEOUT:?}"
|
||||
));
|
||||
}
|
||||
std::thread::sleep(REMOTE_EXEC_SERVER_POLL_INTERVAL);
|
||||
fn remote_exec_server_url() -> Result<String> {
|
||||
let listen_url = std::env::var(REMOTE_EXEC_SERVER_URL_ENV_VAR).with_context(|| {
|
||||
format!("{REMOTE_EXEC_SERVER_URL_ENV_VAR} must be set for remote tests")
|
||||
})?;
|
||||
let listen_url = listen_url.trim();
|
||||
if listen_url.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"{REMOTE_EXEC_SERVER_URL_ENV_VAR} must not be empty"
|
||||
));
|
||||
}
|
||||
Ok(listen_url.to_string())
|
||||
}
|
||||
|
||||
fn remote_exec_server_instance_id() -> String {
|
||||
let instance = REMOTE_EXEC_SERVER_INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
fn remote_test_instance_id() -> String {
|
||||
let instance = REMOTE_TEST_INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("{}-{instance}", std::process::id())
|
||||
}
|
||||
|
||||
fn remote_container_ip(container_name: &str) -> Result<String> {
|
||||
let ip = docker_command_capture_stdout([
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
|
||||
container_name,
|
||||
])?;
|
||||
let ip = ip.trim();
|
||||
if ip.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"container `{container_name}` has no IP address; cannot connect to remote exec-server"
|
||||
));
|
||||
}
|
||||
Ok(ip.to_string())
|
||||
}
|
||||
|
||||
fn rewrite_websocket_host(listen_url: &str, host: &str) -> Result<String> {
|
||||
let Some(address) = listen_url.strip_prefix("ws://") else {
|
||||
return Err(anyhow!(
|
||||
"unexpected websocket listen URL `{listen_url}`; expected ws://IP:PORT"
|
||||
));
|
||||
};
|
||||
let Some((_, port)) = address.rsplit_once(':') else {
|
||||
return Err(anyhow!(
|
||||
"unexpected websocket listen URL `{listen_url}`; expected ws://IP:PORT"
|
||||
));
|
||||
};
|
||||
Ok(format!("ws://{host}:{port}"))
|
||||
}
|
||||
|
||||
fn docker_command_success<const N: usize>(args: [&str; N]) -> Result<()> {
|
||||
let output = Command::new("docker")
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| format!("run docker {args:?}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"docker {:?} failed: stdout={} stderr={}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stdout).trim(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn docker_command_capture_stdout<const N: usize>(args: [&str; N]) -> Result<String> {
|
||||
let output = Command::new("docker")
|
||||
.args(args)
|
||||
|
||||
@@ -177,12 +177,25 @@ async fn run_code_mode_turn_with_rmcp(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
code: &str,
|
||||
) -> Result<(TestCodex, ResponseMock)> {
|
||||
run_code_mode_turn_with_rmcp_mode(server, prompt, code, /*code_mode_only*/ false).await
|
||||
}
|
||||
|
||||
async fn run_code_mode_turn_with_rmcp_mode(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
code: &str,
|
||||
code_mode_only: bool,
|
||||
) -> Result<(TestCodex, ResponseMock)> {
|
||||
let rmcp_test_server_bin = stdio_server_bin()?;
|
||||
let mut builder = test_codex()
|
||||
.with_model("test-gpt-5.1-codex")
|
||||
.with_config(move |config| {
|
||||
let _ = config.features.enable(Feature::CodeMode);
|
||||
let _ = if code_mode_only {
|
||||
config.features.enable(Feature::CodeModeOnly)
|
||||
} else {
|
||||
config.features.enable(Feature::CodeMode)
|
||||
};
|
||||
|
||||
let mut servers = config.mcp_servers.get().clone();
|
||||
servers.insert(
|
||||
@@ -1989,6 +2002,36 @@ contentLength=0"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_only_can_call_mcp_tool() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let code = r#"
|
||||
const result = await tools.mcp__rmcp__echo({ message: "ping" });
|
||||
text(`echo=${result.structuredContent?.echo ?? "missing"}`);
|
||||
"#;
|
||||
|
||||
let (_test, second_mock) = run_code_mode_turn_with_rmcp_mode(
|
||||
&server,
|
||||
"use exec to run the rmcp echo tool in code mode only",
|
||||
code,
|
||||
/*code_mode_only*/ true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let (output, success) = custom_tool_output_body_and_success(&req, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"code_mode_only rmcp tool call failed unexpectedly: {output}"
|
||||
);
|
||||
assert_eq!(output, "echo=ECHOING: ping");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_exposes_mcp_tools_on_global_tools_object() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::string::ToString;
|
||||
|
||||
use codex_core::exec::ExecCapturePolicy;
|
||||
use codex_core::exec::ExecParams;
|
||||
use codex_core::exec::process_exec_tool_call;
|
||||
@@ -17,6 +14,7 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_sandboxing::get_platform_sandbox;
|
||||
use core_test_support::PathExt;
|
||||
use std::collections::HashMap;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn skip_test() -> bool {
|
||||
@@ -29,14 +27,18 @@ fn skip_test() -> bool {
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput> {
|
||||
async fn run_test_cmd<I, S>(tmp: TempDir, command: I) -> Result<ExecToolCallOutput>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
let sandbox_type = get_platform_sandbox(/*windows_sandbox_enabled*/ false)
|
||||
.expect("should be able to get sandbox type");
|
||||
assert_eq!(sandbox_type, SandboxType::MacosSeatbelt);
|
||||
let cwd = tmp.path().abs();
|
||||
|
||||
let params = ExecParams {
|
||||
command: cmd.iter().map(ToString::to_string).collect(),
|
||||
command: command.into_iter().map(Into::into).collect(),
|
||||
cwd: cwd.clone(),
|
||||
expiration: 1000.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
@@ -129,6 +131,37 @@ async fn exit_command_not_found_is_ok() {
|
||||
run_test_cmd(tmp, cmd).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openpty_works_under_real_exec_seatbelt_path() {
|
||||
if skip_test() {
|
||||
return;
|
||||
}
|
||||
|
||||
let python = match which::which("python3") {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
eprintln!("python3 not found in PATH, skipping test.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let cmd = vec![
|
||||
python.to_string_lossy().into_owned(),
|
||||
"-c".to_string(),
|
||||
r#"import os
|
||||
|
||||
master, slave = os.openpty()
|
||||
os.write(slave, b"ping")
|
||||
assert os.read(master, 4) == b"ping""#
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let output = run_test_cmd(tmp, cmd).await.unwrap();
|
||||
assert_eq!(output.stdout.text, "");
|
||||
assert_eq!(output.stderr.text, "");
|
||||
}
|
||||
|
||||
/// Writing a file fails and should be considered a sandbox error
|
||||
#[tokio::test]
|
||||
async fn write_file_fails_as_sandbox_error() {
|
||||
@@ -139,7 +172,7 @@ async fn write_file_fails_as_sandbox_error() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let path = tmp.path().join("test.txt");
|
||||
let cmd = vec![
|
||||
"/user/bin/touch",
|
||||
"/usr/bin/touch",
|
||||
path.to_str().expect("should be able to get path"),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use core_test_support::responses;
|
||||
@@ -12,12 +14,15 @@ use core_test_support::responses::ev_custom_tool_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
@@ -594,6 +599,82 @@ async fn js_repl_can_invoke_builtin_tools() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_can_invoke_mcp_tools_by_display_name() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let rmcp_test_server_bin = stdio_server_bin()?;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::JsRepl)
|
||||
.expect("test config should allow feature update");
|
||||
|
||||
let mut servers = config.mcp_servers.get().clone();
|
||||
servers.insert(
|
||||
"rmcp".to_string(),
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: rmcp_test_server_bin,
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
config
|
||||
.mcp_servers
|
||||
.set(servers)
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(
|
||||
"call-1",
|
||||
"js_repl",
|
||||
r#"
|
||||
const result = await codex.tool("mcp__rmcp__echo", { message: "ping" });
|
||||
console.log(result.output);
|
||||
"#,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let final_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("use js_repl to call an MCP tool").await?;
|
||||
|
||||
let req = final_mock.single_request();
|
||||
assert_js_repl_ok(&req, "call-1", "ECHOING: ping");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_tool_call_rejects_recursive_js_repl_invocation() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -159,6 +159,145 @@ async fn memories_startup_phase2_tracks_added_and_removed_inputs_across_runs() -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn memories_startup_phase2_prunes_old_extension_resources_and_reports_them() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let db = init_state_db(&home).await?;
|
||||
let now = Utc::now();
|
||||
let thread_id = seed_stage1_output(
|
||||
db.as_ref(),
|
||||
home.path(),
|
||||
now - ChronoDuration::hours(1),
|
||||
"raw memory",
|
||||
"rollout summary",
|
||||
"rollout",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let telepathy_resources = home.path().join("memories_extensions/telepathy/resources");
|
||||
tokio::fs::create_dir_all(&telepathy_resources).await?;
|
||||
tokio::fs::write(
|
||||
home.path()
|
||||
.join("memories_extensions/telepathy/instructions.md"),
|
||||
"instructions",
|
||||
)
|
||||
.await?;
|
||||
let old_file_name = format!(
|
||||
"{}-abcd-10min-old.md",
|
||||
(now - ChronoDuration::days(8)).format("%Y-%m-%dT%H-%M-%S")
|
||||
);
|
||||
let old_file = telepathy_resources.join(&old_file_name);
|
||||
tokio::fs::write(&old_file, "old resource").await?;
|
||||
let recent_file = telepathy_resources.join(format!(
|
||||
"{}-abcd-10min-recent.md",
|
||||
(now - ChronoDuration::days(6)).format("%Y-%m-%dT%H-%M-%S")
|
||||
));
|
||||
tokio::fs::write(&recent_file, "recent resource").await?;
|
||||
|
||||
let phase2 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-phase2"),
|
||||
ev_assistant_message("msg-phase2", "phase2 complete"),
|
||||
ev_completed("resp-phase2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex = build_test_codex(&server, home.clone()).await?;
|
||||
let request = wait_for_single_request(&phase2).await;
|
||||
let prompt = phase2_prompt_text(&request);
|
||||
|
||||
assert!(
|
||||
prompt.contains("Memory extension resources removed by retention pruning:"),
|
||||
"expected extension resource prune report in prompt: {prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("- retention window: 7 days"),
|
||||
"expected retention window in prompt: {prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("- extension: telepathy"),
|
||||
"expected extension name in prompt: {prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains(&format!(" - resources/{old_file_name}")),
|
||||
"expected old resource in prompt: {prompt}"
|
||||
);
|
||||
|
||||
wait_for_phase2_success(db.as_ref(), thread_id).await?;
|
||||
wait_for_file_removed(&old_file).await?;
|
||||
assert!(
|
||||
!tokio::fs::try_exists(&old_file).await?,
|
||||
"old extension resource should be pruned"
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&recent_file).await?,
|
||||
"recent extension resource should be retained"
|
||||
);
|
||||
|
||||
shutdown_test_codex(&codex).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn memories_startup_phase2_processes_old_extension_resources_without_stage1_input()
|
||||
-> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let db = init_state_db(&home).await?;
|
||||
db.enqueue_global_consolidation(/*input_watermark*/ 1)
|
||||
.await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let telepathy_resources = home.path().join("memories_extensions/telepathy/resources");
|
||||
tokio::fs::create_dir_all(&telepathy_resources).await?;
|
||||
tokio::fs::write(
|
||||
home.path()
|
||||
.join("memories_extensions/telepathy/instructions.md"),
|
||||
"instructions",
|
||||
)
|
||||
.await?;
|
||||
let old_file_name = format!(
|
||||
"{}-abcd-10min-old.md",
|
||||
(now - ChronoDuration::days(8)).format("%Y-%m-%dT%H-%M-%S")
|
||||
);
|
||||
let old_file = telepathy_resources.join(&old_file_name);
|
||||
tokio::fs::write(&old_file, "old resource").await?;
|
||||
|
||||
let phase2 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-phase2-empty"),
|
||||
ev_assistant_message("msg-phase2-empty", "phase2 complete"),
|
||||
ev_completed("resp-phase2-empty"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex = build_test_codex(&server, home.clone()).await?;
|
||||
let request = wait_for_single_request(&phase2).await;
|
||||
let prompt = phase2_prompt_text(&request);
|
||||
|
||||
assert!(
|
||||
prompt.contains("- selected inputs this run: 0"),
|
||||
"expected no selected raw inputs in prompt: {prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Memory extension resources removed by retention pruning:"),
|
||||
"expected extension resource prune report in prompt: {prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains(&format!(" - resources/{old_file_name}")),
|
||||
"expected old resource in prompt: {prompt}"
|
||||
);
|
||||
wait_for_file_removed(&old_file).await?;
|
||||
|
||||
shutdown_test_codex(&codex).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn web_search_pollution_moves_selected_thread_into_removed_phase2_inputs() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
@@ -386,6 +525,21 @@ async fn wait_for_single_request(mock: &ResponseMock) -> ResponsesRequest {
|
||||
wait_for_request(mock, /*expected_count*/ 1).await.remove(0)
|
||||
}
|
||||
|
||||
async fn wait_for_file_removed(path: &Path) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if !tokio::fs::try_exists(path).await? {
|
||||
return Ok(());
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for {} to be removed",
|
||||
path.display()
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_request(mock: &ResponseMock, expected_count: usize) -> Vec<ResponsesRequest> {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
|
||||
@@ -85,7 +85,6 @@ mod rmcp_client;
|
||||
mod rollout_list_find;
|
||||
mod safety_check_downgrade;
|
||||
mod search_tool;
|
||||
mod seatbelt;
|
||||
mod shell_command;
|
||||
mod shell_serialization;
|
||||
mod shell_snapshot;
|
||||
|
||||
@@ -10,7 +10,7 @@ use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::apps_test_server::DOCUMENT_EXTRACT_TEXT_RESOURCE_URI;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_function_call_with_namespace;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
@@ -26,7 +26,8 @@ use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DOCUMENT_EXTRACT_TOOL: &str = "mcp__codex_apps__calendar_extract_text";
|
||||
const DOCUMENT_EXTRACT_NAMESPACE: &str = "mcp__codex_apps__calendar";
|
||||
const DOCUMENT_EXTRACT_TOOL: &str = "_extract_text";
|
||||
|
||||
fn configure_apps(config: &mut Config, chatgpt_base_url: &str) {
|
||||
if let Err(err) = config.features.enable(Feature::Apps) {
|
||||
@@ -35,18 +36,6 @@ fn configure_apps(config: &mut Config, chatgpt_base_url: &str) {
|
||||
config.chatgpt_base_url = chatgpt_base_url.to_string();
|
||||
}
|
||||
|
||||
fn tool_by_name<'a>(body: &'a Value, name: &str) -> &'a Value {
|
||||
body.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|tools| {
|
||||
tools.iter().find(|tool| {
|
||||
tool.get("name").and_then(Value::as_str) == Some(name)
|
||||
|| tool.get("type").and_then(Value::as_str) == Some(name)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| panic!("missing tool {name} in /v1/responses request: {body:?}"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
@@ -93,8 +82,9 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
ev_function_call_with_namespace(
|
||||
call_id,
|
||||
DOCUMENT_EXTRACT_NAMESPACE,
|
||||
DOCUMENT_EXTRACT_TOOL,
|
||||
&json!({"file": "report.txt"}).to_string(),
|
||||
),
|
||||
@@ -123,8 +113,14 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
|
||||
.await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
let body = requests[0].body_json();
|
||||
let extract_tool = tool_by_name(&body, DOCUMENT_EXTRACT_TOOL);
|
||||
let Some(extract_tool) =
|
||||
requests[0].tool_by_name(DOCUMENT_EXTRACT_NAMESPACE, DOCUMENT_EXTRACT_TOOL)
|
||||
else {
|
||||
let body = requests[0].body_json();
|
||||
panic!(
|
||||
"missing tool {DOCUMENT_EXTRACT_NAMESPACE}{DOCUMENT_EXTRACT_TOOL} in /v1/responses request: {body:?}"
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
extract_tool.pointer("/parameters/properties/file"),
|
||||
Some(&json!({
|
||||
|
||||
@@ -168,22 +168,6 @@ fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn tool_description(body: &serde_json::Value, tool_name: &str) -> Option<String> {
|
||||
body.get("tools")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|tools| {
|
||||
tools.iter().find_map(|tool| {
|
||||
if tool.get("name").and_then(serde_json::Value::as_str) == Some(tool_name) {
|
||||
tool.get("description")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn capability_sections_render_in_developer_message_in_order() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -319,20 +303,27 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
|
||||
assert!(
|
||||
request_tools
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__google_calendar_create_event"),
|
||||
.any(|name| name == "mcp__codex_apps__google_calendar"),
|
||||
"expected plugin app tools to become visible for this turn: {request_tools:?}"
|
||||
);
|
||||
let echo_description = tool_description(&request_body, "mcp__sample__echo")
|
||||
let echo_tool = request
|
||||
.tool_by_name("mcp__sample__", "echo")
|
||||
.expect("plugin MCP tool should be present");
|
||||
let echo_description = echo_tool
|
||||
.get("description")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("plugin MCP tool description should be present");
|
||||
assert!(
|
||||
echo_description.contains("This tool is part of plugin `sample`."),
|
||||
"expected plugin MCP provenance in tool description: {echo_description:?}"
|
||||
);
|
||||
let calendar_description = tool_description(
|
||||
&request_body,
|
||||
"mcp__codex_apps__google_calendar_create_event",
|
||||
)
|
||||
.expect("plugin app tool description should be present");
|
||||
let calendar_tool = request
|
||||
.tool_by_name("mcp__codex_apps__google_calendar", "_create_event")
|
||||
.expect("plugin app tool should be present");
|
||||
let calendar_description = calendar_tool
|
||||
.get("description")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("plugin app tool description should be present");
|
||||
assert!(
|
||||
calendar_description.contains("This tool is part of plugin `sample`."),
|
||||
"expected plugin app provenance in tool description: {calendar_description:?}"
|
||||
|
||||
@@ -2417,11 +2417,8 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> {
|
||||
|
||||
let request = response_mock.single_request();
|
||||
let user_texts = request.message_input_texts("user");
|
||||
assert!(
|
||||
user_texts
|
||||
.iter()
|
||||
.any(|text| text == "user: text from realtime")
|
||||
);
|
||||
assert!(user_texts.iter().any(|text| text
|
||||
== "<realtime_delegation>\n <input>user: text from realtime</input>\n</realtime_delegation>"));
|
||||
|
||||
realtime_server.shutdown().await;
|
||||
Ok(())
|
||||
@@ -2503,7 +2500,7 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> {
|
||||
let request = response_mock.single_request();
|
||||
let user_texts = request.message_input_texts("user");
|
||||
assert!(user_texts.iter().any(|text| text
|
||||
== "assistant: assistant context\nuser: delegated query\nassistant: assist confirm"));
|
||||
== "<realtime_delegation>\n <input>assistant: assistant context\nuser: delegated query\nassistant: assist confirm</input>\n</realtime_delegation>"));
|
||||
|
||||
realtime_server.shutdown().await;
|
||||
Ok(())
|
||||
@@ -2617,23 +2614,14 @@ async fn inbound_handoff_request_clears_active_transcript_after_each_handoff() -
|
||||
assert_eq!(requests.len(), 2);
|
||||
|
||||
let first_user_texts = requests[0].message_input_texts("user");
|
||||
assert!(
|
||||
first_user_texts
|
||||
.iter()
|
||||
.any(|text| text == "user: first question")
|
||||
);
|
||||
assert!(first_user_texts.iter().any(|text| text
|
||||
== "<realtime_delegation>\n <input>user: first question</input>\n</realtime_delegation>"));
|
||||
|
||||
let second_user_texts = requests[1].message_input_texts("user");
|
||||
assert!(
|
||||
second_user_texts
|
||||
.iter()
|
||||
.any(|text| text == "user: second question")
|
||||
);
|
||||
assert!(
|
||||
!second_user_texts
|
||||
.iter()
|
||||
.any(|text| text == "user: first question\nuser: second question")
|
||||
);
|
||||
assert!(second_user_texts.iter().any(|text| text
|
||||
== "<realtime_delegation>\n <input>user: second question</input>\n</realtime_delegation>"));
|
||||
assert!(!second_user_texts.iter().any(|text| text
|
||||
== "<realtime_delegation>\n <input>user: first question\nuser: second question</input>\n</realtime_delegation>"));
|
||||
|
||||
realtime_server.shutdown().await;
|
||||
Ok(())
|
||||
@@ -3156,14 +3144,12 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
|
||||
assert!(
|
||||
!first_texts
|
||||
.iter()
|
||||
.any(|text| text == "user: steer via realtime")
|
||||
.any(|text| text
|
||||
== "<realtime_delegation>\n <input>user: steer via realtime</input>\n</realtime_delegation>")
|
||||
);
|
||||
assert!(second_texts.iter().any(|text| text == "first prompt"));
|
||||
assert!(
|
||||
second_texts
|
||||
.iter()
|
||||
.any(|text| text == "user: steer via realtime")
|
||||
);
|
||||
assert!(second_texts.iter().any(|text| text
|
||||
== "<realtime_delegation>\n <input>user: steer via realtime</input>\n</realtime_delegation>"));
|
||||
|
||||
realtime_server.shutdown().await;
|
||||
api_server.shutdown().await;
|
||||
@@ -3278,7 +3264,9 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio()
|
||||
assert_eq!(requests.len(), 1);
|
||||
let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request");
|
||||
let first_texts = message_input_texts(&first_body, "user");
|
||||
let expected_text = format!("user: {delegated_text}");
|
||||
let expected_text = format!(
|
||||
"<realtime_delegation>\n <input>user: {delegated_text}</input>\n</realtime_delegation>"
|
||||
);
|
||||
assert!(first_texts.iter().any(|text| text == &expected_text));
|
||||
|
||||
realtime_server.shutdown().await;
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
@@ -13,10 +18,12 @@ use core_test_support::responses::mount_sse_once_match;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::io::ErrorKind;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::Child;
|
||||
@@ -29,8 +36,9 @@ use tempfile::TempDir;
|
||||
const PARENT_PROMPT: &str = "spawn a subagent and report when it is started";
|
||||
const CHILD_PROMPT: &str = "child: say done";
|
||||
const SPAWN_CALL_ID: &str = "spawn-call-1";
|
||||
const PROXY_START_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5);
|
||||
const PROXY_START_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
|
||||
const PROXY_POLL_INTERVAL: Duration = Duration::from_millis(/*millis*/ 20);
|
||||
const TURN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 60);
|
||||
|
||||
struct ResponsesApiProxy {
|
||||
child: Child,
|
||||
@@ -40,8 +48,17 @@ struct ResponsesApiProxy {
|
||||
impl ResponsesApiProxy {
|
||||
fn start(upstream_url: &str, dump_dir: &Path) -> Result<Self> {
|
||||
let server_info = dump_dir.join("server-info.json");
|
||||
let mut child = StdCommand::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
||||
.args(["responses-api-proxy", "--server-info"])
|
||||
let (proxy_program, use_codex_multitool) =
|
||||
match codex_utils_cargo_bin::cargo_bin("codex-responses-api-proxy") {
|
||||
Ok(path) => (path, false),
|
||||
Err(_) => (codex_utils_cargo_bin::cargo_bin("codex")?, true),
|
||||
};
|
||||
let mut command = StdCommand::new(proxy_program);
|
||||
if use_codex_multitool {
|
||||
command.arg("responses-api-proxy");
|
||||
}
|
||||
let mut child = command
|
||||
.args(["--server-info"])
|
||||
.arg(&server_info)
|
||||
.args(["--upstream-url", upstream_url, "--dump-dir"])
|
||||
.arg(dump_dir)
|
||||
@@ -58,15 +75,27 @@ impl ResponsesApiProxy {
|
||||
|
||||
let deadline = Instant::now() + PROXY_START_TIMEOUT;
|
||||
loop {
|
||||
if let Ok(info) = std::fs::read_to_string(&server_info) {
|
||||
let port = serde_json::from_str::<Value>(&info)?
|
||||
.get("port")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| anyhow!("proxy server info missing port"))?;
|
||||
return Ok(Self {
|
||||
child,
|
||||
port: u16::try_from(port)?,
|
||||
});
|
||||
match std::fs::read_to_string(&server_info) {
|
||||
Ok(info) => {
|
||||
if !info.trim().is_empty() {
|
||||
match serde_json::from_str::<Value>(&info) {
|
||||
Ok(info) => {
|
||||
let port = info
|
||||
.get("port")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| anyhow!("proxy server info missing port"))?;
|
||||
return Ok(Self {
|
||||
child,
|
||||
port: u16::try_from(port)?,
|
||||
});
|
||||
}
|
||||
Err(err) if err.is_eof() => {}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Err(anyhow!(
|
||||
@@ -144,7 +173,7 @@ async fn responses_api_proxy_dumps_parent_and_subagent_identity_headers() -> Res
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
test.submit_turn(PARENT_PROMPT).await?;
|
||||
submit_turn_with_timeout(&test, PARENT_PROMPT, dump_dir.path()).await?;
|
||||
|
||||
let dumps = wait_for_proxy_request_dumps(dump_dir.path())?;
|
||||
let parent = dumps
|
||||
@@ -178,6 +207,85 @@ async fn responses_api_proxy_dumps_parent_and_subagent_identity_headers() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str, dump_dir: &Path) -> Result<()> {
|
||||
let session_model = test.session_configured.model.clone();
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: prompt.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.config.cwd.to_path_buf(),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
},
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let turn_started = wait_for_event_result(test, "turn started", dump_dir, |event| {
|
||||
matches!(event, EventMsg::TurnStarted(_))
|
||||
})
|
||||
.await?;
|
||||
let EventMsg::TurnStarted(turn_started) = turn_started else {
|
||||
unreachable!("event predicate only matches turn started events");
|
||||
};
|
||||
wait_for_event_result(test, "turn complete", dump_dir, |event| match event {
|
||||
EventMsg::TurnComplete(event) => event.turn_id == turn_started.turn_id,
|
||||
_ => false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_event_result<F>(
|
||||
test: &TestCodex,
|
||||
stage: &str,
|
||||
dump_dir: &Path,
|
||||
mut predicate: F,
|
||||
) -> Result<EventMsg>
|
||||
where
|
||||
F: FnMut(&EventMsg) -> bool,
|
||||
{
|
||||
let mut seen_events = Vec::new();
|
||||
tokio::time::timeout(TURN_TIMEOUT, async {
|
||||
loop {
|
||||
let event = test.codex.next_event().await?;
|
||||
seen_events.push(event_summary(&event.msg));
|
||||
if predicate(&event.msg) {
|
||||
return Ok::<EventMsg, anyhow::Error>(event.msg);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"timed out waiting for {stage}; saw events: {}; {}",
|
||||
seen_events.join(" | "),
|
||||
proxy_dump_summary(dump_dir)
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
fn event_summary(event: &EventMsg) -> String {
|
||||
let mut summary = format!("{event:?}");
|
||||
summary.truncate(240);
|
||||
summary
|
||||
}
|
||||
|
||||
fn request_body_contains(req: &wiremock::Request, text: &str) -> bool {
|
||||
std::str::from_utf8(&req.body).is_ok_and(|body| body.contains(text))
|
||||
}
|
||||
@@ -212,12 +320,54 @@ fn read_proxy_request_dumps(dump_dir: &Path) -> Result<Vec<Value>> {
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.ends_with("-request.json"))
|
||||
{
|
||||
dumps.push(serde_json::from_str(&std::fs::read_to_string(&path)?)?);
|
||||
let contents = std::fs::read_to_string(&path)?;
|
||||
if contents.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match serde_json::from_str(&contents) {
|
||||
Ok(dump) => dumps.push(dump),
|
||||
Err(err) if err.is_eof() => continue,
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(dumps)
|
||||
}
|
||||
|
||||
fn proxy_dump_summary(dump_dir: &Path) -> String {
|
||||
match read_proxy_request_dumps(dump_dir) {
|
||||
Ok(dumps) => {
|
||||
let bodies = dumps
|
||||
.iter()
|
||||
.filter_map(|dump| dump.get("body"))
|
||||
.map(Value::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
format!("proxy wrote {} request dumps: {bodies}", dumps.len())
|
||||
}
|
||||
Err(err) => format!("failed to read proxy request dumps: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_proxy_request_dumps_ignores_in_progress_files() -> Result<()> {
|
||||
let dump_dir = TempDir::new()?;
|
||||
std::fs::write(dump_dir.path().join("empty-request.json"), "")?;
|
||||
std::fs::write(dump_dir.path().join("partial-request.json"), "{\"body\"")?;
|
||||
std::fs::write(
|
||||
dump_dir.path().join("complete-request.json"),
|
||||
serde_json::to_string(&json!({ "body": "ready" }))?,
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
read_proxy_request_dumps(dump_dir.path())?,
|
||||
vec![json!({ "body": "ready" })]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dump_body_contains(dump: &Value, text: &str) -> bool {
|
||||
dump.get("body")
|
||||
.is_some_and(|body| body.to_string().contains(text))
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::time::UNIX_EPOCH;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
@@ -90,13 +91,18 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
|
||||
let call_id = "call-123";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
mount_sse_once(
|
||||
let call_mock = mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
"{\"message\":\"ping\"}",
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -222,6 +228,13 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let output_item = final_mock.single_request().function_call_output(call_id);
|
||||
let request = call_mock.single_request();
|
||||
assert!(
|
||||
request.tool_by_name(&namespace, "echo").is_some(),
|
||||
"direct MCP tool should be sent as a namespace child tool: {:?}",
|
||||
request.body_json()
|
||||
);
|
||||
|
||||
let output_text = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
@@ -237,6 +250,138 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
|
||||
let call_id = "sandbox-meta-call";
|
||||
let server_name = "rmcp";
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
let tool_name = format!("{namespace}sandbox_meta");
|
||||
|
||||
let call_mock = mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call_with_namespace(call_id, &namespace, "sandbox_meta", "{}"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let final_mock = mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("msg-1", "rmcp sandbox meta completed successfully."),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let rmcp_test_server_bin = stdio_server_bin()?;
|
||||
let fixture = test_codex()
|
||||
.with_config(move |config| {
|
||||
let mut servers = config.mcp_servers.get().clone();
|
||||
servers.insert(
|
||||
server_name.to_string(),
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: rmcp_test_server_bin,
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
config
|
||||
.mcp_servers
|
||||
.set(servers)
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
let tools_ready_deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
fixture.codex.submit(Op::ListMcpTools).await?;
|
||||
let list_event = wait_for_event_with_timeout(
|
||||
&fixture.codex,
|
||||
|ev| matches!(ev, EventMsg::McpListToolsResponse(_)),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
let EventMsg::McpListToolsResponse(tool_list) = list_event else {
|
||||
unreachable!("event guard guarantees McpListToolsResponse");
|
||||
};
|
||||
if tool_list.tools.contains_key(&tool_name) {
|
||||
break;
|
||||
}
|
||||
|
||||
let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect();
|
||||
if Instant::now() >= tools_ready_deadline {
|
||||
panic!(
|
||||
"timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}"
|
||||
);
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
fixture
|
||||
.submit_turn_with_policy("call the rmcp sandbox_meta tool", sandbox_policy.clone())
|
||||
.await?;
|
||||
|
||||
let request = call_mock.single_request();
|
||||
assert!(
|
||||
request.tool_by_name(&namespace, "sandbox_meta").is_some(),
|
||||
"direct MCP tool should be sent as a namespace child tool: {:?}",
|
||||
request.body_json()
|
||||
);
|
||||
|
||||
let output_item = final_mock.single_request().function_call_output(call_id);
|
||||
let output_text = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("function_call_output output should be a string");
|
||||
let wrapped_payload = split_wall_time_wrapped_output(output_text);
|
||||
let output_json: Value = serde_json::from_str(wrapped_payload)
|
||||
.expect("wrapped MCP output should preserve sandbox metadata JSON");
|
||||
let Value::Object(meta) = output_json else {
|
||||
panic!("sandbox_meta should return metadata object: {output_json:?}");
|
||||
};
|
||||
|
||||
let sandbox_meta = meta
|
||||
.get(MCP_SANDBOX_STATE_META_CAPABILITY)
|
||||
.expect("sandbox state metadata should be present");
|
||||
let expected_sandbox_policy = serde_json::to_value(&sandbox_policy)?;
|
||||
assert_eq!(
|
||||
sandbox_meta.get("sandboxPolicy"),
|
||||
Some(&expected_sandbox_policy)
|
||||
);
|
||||
assert_eq!(
|
||||
sandbox_meta.get("sandboxCwd").and_then(Value::as_str),
|
||||
fixture.cwd.path().to_str()
|
||||
);
|
||||
assert_eq!(sandbox_meta.get("useLegacyLandlock"), Some(&json!(false)));
|
||||
|
||||
server.verify().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -246,15 +391,15 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow::
|
||||
let first_call_id = "sync-serial-1";
|
||||
let second_call_id = "sync-serial-2";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__sync");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
let args = json!({ "sleep_after_ms": 100 }).to_string();
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(first_call_id, &tool_name, &args),
|
||||
responses::ev_function_call(second_call_id, &tool_name, &args),
|
||||
responses::ev_function_call_with_namespace(first_call_id, &namespace, "sync", &args),
|
||||
responses::ev_function_call_with_namespace(second_call_id, &namespace, "sync", &args),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -388,7 +533,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res
|
||||
let first_call_id = "sync-1";
|
||||
let second_call_id = "sync-2";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__sync");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
let args = json!({
|
||||
"sleep_after_ms": 100,
|
||||
"barrier": {
|
||||
@@ -403,8 +548,8 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(first_call_id, &tool_name, &args),
|
||||
responses::ev_function_call(second_call_id, &tool_name, &args),
|
||||
responses::ev_function_call_with_namespace(first_call_id, &namespace, "sync", &args),
|
||||
responses::ev_function_call_with_namespace(second_call_id, &namespace, "sync", &args),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -504,13 +649,14 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
|
||||
let call_id = "img-1";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__image");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
// First stream: model decides to call the image tool.
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{}"),
|
||||
responses::ev_function_call_with_namespace(call_id, &namespace, "image", "{}"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -693,7 +839,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
|
||||
let call_id = "img-text-only-1";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__image");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
let text_only_model_slug = "rmcp-text-only-model";
|
||||
|
||||
let models_mock = mount_models_once(
|
||||
@@ -743,7 +889,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{}"),
|
||||
responses::ev_function_call_with_namespace(call_id, &namespace, "image", "{}"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -864,13 +1010,18 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
|
||||
|
||||
let call_id = "call-1234";
|
||||
let server_name = "rmcp_whitelist";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
"{\"message\":\"ping\"}",
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -1006,13 +1157,18 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
|
||||
|
||||
let call_id = "call-456";
|
||||
let server_name = "rmcp_http";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
"{\"message\":\"ping\"}",
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -1211,12 +1367,18 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
|
||||
let call_id = "call-789";
|
||||
let server_name = "rmcp_http_oauth";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
"{\"message\":\"ping\"}",
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::ev_tool_search_call;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::namespace_child_tool;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
@@ -45,6 +46,7 @@ const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event";
|
||||
const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar_list_events";
|
||||
const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar";
|
||||
const SEARCH_CALENDAR_CREATE_TOOL: &str = "_create_event";
|
||||
const SEARCH_CALENDAR_LIST_TOOL: &str = "_list_events";
|
||||
|
||||
fn tool_names(body: &Value) -> Vec<String> {
|
||||
body.get("tools")
|
||||
@@ -215,8 +217,17 @@ async fn tool_search_disabled_by_default_exposes_apps_tools_directly() -> Result
|
||||
let body = mock.single_request().body_json();
|
||||
let tools = tool_names(&body);
|
||||
assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME));
|
||||
assert!(tools.iter().any(|name| name == CALENDAR_CREATE_TOOL));
|
||||
assert!(tools.iter().any(|name| name == CALENDAR_LIST_TOOL));
|
||||
assert!(
|
||||
namespace_child_tool(
|
||||
&body,
|
||||
SEARCH_CALENDAR_NAMESPACE,
|
||||
SEARCH_CALENDAR_CREATE_TOOL
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
namespace_child_tool(&body, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_LIST_TOOL).is_some()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -332,6 +343,7 @@ async fn search_tool_hides_apps_tools_without_search() -> Result<()> {
|
||||
assert!(tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME));
|
||||
assert!(!tools.iter().any(|name| name == CALENDAR_CREATE_TOOL));
|
||||
assert!(!tools.iter().any(|name| name == CALENDAR_LIST_TOOL));
|
||||
assert!(!tools.iter().any(|name| name == SEARCH_CALENDAR_NAMESPACE));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -365,11 +377,16 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()>
|
||||
let body = mock.single_request().body_json();
|
||||
let tools = tool_names(&body);
|
||||
assert!(
|
||||
tools.iter().any(|name| name == CALENDAR_CREATE_TOOL),
|
||||
namespace_child_tool(
|
||||
&body,
|
||||
SEARCH_CALENDAR_NAMESPACE,
|
||||
SEARCH_CALENDAR_CREATE_TOOL
|
||||
)
|
||||
.is_some(),
|
||||
"expected explicit app mention to expose create tool, got tools: {tools:?}"
|
||||
);
|
||||
assert!(
|
||||
tools.iter().any(|name| name == CALENDAR_LIST_TOOL),
|
||||
namespace_child_tool(&body, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_LIST_TOOL).is_some(),
|
||||
"expected explicit app mention to expose list tool, got tools: {tools:?}"
|
||||
);
|
||||
|
||||
@@ -523,6 +540,12 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -
|
||||
.any(|name| name == CALENDAR_CREATE_TOOL),
|
||||
"app tools should still be hidden before search: {first_request_tools:?}"
|
||||
);
|
||||
assert!(
|
||||
!first_request_tools
|
||||
.iter()
|
||||
.any(|name| name == SEARCH_CALENDAR_NAMESPACE),
|
||||
"app namespace should still be hidden before search: {first_request_tools:?}"
|
||||
);
|
||||
|
||||
let output_item = tool_search_output_item(&requests[1], call_id);
|
||||
assert_eq!(
|
||||
@@ -570,6 +593,12 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -
|
||||
.any(|name| name == CALENDAR_CREATE_TOOL),
|
||||
"follow-up request should rely on tool_search_output history, not tool injection: {second_request_tools:?}"
|
||||
);
|
||||
assert!(
|
||||
!second_request_tools
|
||||
.iter()
|
||||
.any(|name| name == SEARCH_CALENDAR_NAMESPACE),
|
||||
"follow-up request should rely on tool_search_output history, not namespace injection: {second_request_tools:?}"
|
||||
);
|
||||
|
||||
let output_item = requests[2].function_call_output("calendar-call-1");
|
||||
assert_eq!(
|
||||
@@ -584,6 +613,12 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -
|
||||
.any(|name| name == CALENDAR_CREATE_TOOL),
|
||||
"post-tool follow-up should still rely on tool_search_output history, not tool injection: {third_request_tools:?}"
|
||||
);
|
||||
assert!(
|
||||
!third_request_tools
|
||||
.iter()
|
||||
.any(|name| name == SEARCH_CALENDAR_NAMESPACE),
|
||||
"post-tool follow-up should still rely on tool_search_output history, not namespace injection: {third_request_tools:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -683,16 +718,19 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> {
|
||||
.any(|name| name == "mcp__rmcp__echo"),
|
||||
"non-app MCP tools should be hidden before search in large-search mode: {first_request_tools:?}"
|
||||
);
|
||||
assert!(
|
||||
!first_request_tools.iter().any(|name| name == "mcp__rmcp__"),
|
||||
"non-app MCP namespace should be hidden before search in large-search mode: {first_request_tools:?}"
|
||||
);
|
||||
|
||||
let echo_tools = tool_search_output_tools(&requests[1], echo_call_id);
|
||||
let rmcp_echo_tools = echo_tools
|
||||
.iter()
|
||||
.filter(|tool| tool.get("name").and_then(Value::as_str) == Some("mcp__rmcp__"))
|
||||
.flat_map(|namespace| namespace.get("tools").and_then(Value::as_array))
|
||||
.flatten()
|
||||
.filter_map(|tool| tool.get("name").and_then(Value::as_str).map(str::to_string))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(rmcp_echo_tools, vec!["echo".to_string()]);
|
||||
let echo_output = json!({ "tools": echo_tools });
|
||||
let rmcp_echo_tool = namespace_child_tool(&echo_output, "mcp__rmcp__", "echo")
|
||||
.expect("tool_search should return rmcp echo as a namespace child tool");
|
||||
assert_eq!(
|
||||
rmcp_echo_tool.get("type").and_then(Value::as_str),
|
||||
Some("function")
|
||||
);
|
||||
|
||||
let image_tools = tool_search_output_tools(&requests[1], image_call_id);
|
||||
let found_rmcp_image_tool = image_tools
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
//! Tests for the macOS sandboxing that are specific to Seatbelt.
|
||||
//! Tests that apply to both Mac and Linux sandboxing should go in sandbox.rs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::seatbelt::spawn_command_under_seatbelt;
|
||||
use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use codex_core::spawn::StdioPolicy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct TestScenario {
|
||||
repo_parent: PathBuf,
|
||||
file_outside_repo: PathBuf,
|
||||
repo_root: PathBuf,
|
||||
file_in_repo_root: PathBuf,
|
||||
file_in_dot_git_dir: PathBuf,
|
||||
}
|
||||
|
||||
struct TestExpectations {
|
||||
file_outside_repo_is_writable: bool,
|
||||
file_in_repo_root_is_writable: bool,
|
||||
file_in_dot_git_dir_is_writable: bool,
|
||||
}
|
||||
|
||||
impl TestScenario {
|
||||
async fn run_test(&self, policy: &SandboxPolicy, expectations: TestExpectations) {
|
||||
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
|
||||
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
|
||||
return;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
touch(&self.file_outside_repo, policy).await,
|
||||
expectations.file_outside_repo_is_writable
|
||||
);
|
||||
assert_eq!(
|
||||
self.file_outside_repo.exists(),
|
||||
expectations.file_outside_repo_is_writable
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
touch(&self.file_in_repo_root, policy).await,
|
||||
expectations.file_in_repo_root_is_writable
|
||||
);
|
||||
assert_eq!(
|
||||
self.file_in_repo_root.exists(),
|
||||
expectations.file_in_repo_root_is_writable
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
touch(&self.file_in_dot_git_dir, policy).await,
|
||||
expectations.file_in_dot_git_dir_is_writable
|
||||
);
|
||||
assert_eq!(
|
||||
self.file_in_dot_git_dir.exists(),
|
||||
expectations.file_in_dot_git_dir_is_writable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// If the user has added a workspace root that is not a Git repo root, then
|
||||
/// the user has to specify `--skip-git-repo-check` or go through some
|
||||
/// interstitial that indicates they are taking on some risk because Git
|
||||
/// cannot be used to backup their work before the agent begins.
|
||||
///
|
||||
/// Because the user has agreed to this risk, we do not try find all .git
|
||||
/// folders in the workspace and block them (though we could change our
|
||||
/// position on this in the future).
|
||||
#[tokio::test]
|
||||
async fn if_parent_of_repo_is_writable_then_dot_git_folder_is_writable() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let test_scenario = create_test_scenario(&tmp);
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![test_scenario.repo_parent.as_path().try_into().unwrap()],
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
test_scenario
|
||||
.run_test(
|
||||
&policy,
|
||||
TestExpectations {
|
||||
file_outside_repo_is_writable: true,
|
||||
file_in_repo_root_is_writable: true,
|
||||
file_in_dot_git_dir_is_writable: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// When the writable root is the root of a Git repository (as evidenced by the
|
||||
/// presence of a .git folder), then the .git folder should be read-only if
|
||||
/// the policy is `WorkspaceWrite`.
|
||||
#[tokio::test]
|
||||
async fn if_git_repo_is_writable_root_then_dot_git_folder_is_read_only() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let test_scenario = create_test_scenario(&tmp);
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![test_scenario.repo_root.as_path().try_into().unwrap()],
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
test_scenario
|
||||
.run_test(
|
||||
&policy,
|
||||
TestExpectations {
|
||||
file_outside_repo_is_writable: false,
|
||||
file_in_repo_root_is_writable: true,
|
||||
file_in_dot_git_dir_is_writable: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Under DangerFullAccess, all writes should be permitted anywhere on disk,
|
||||
/// including inside the .git folder.
|
||||
#[tokio::test]
|
||||
async fn danger_full_access_allows_all_writes() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let test_scenario = create_test_scenario(&tmp);
|
||||
let policy = SandboxPolicy::DangerFullAccess;
|
||||
|
||||
test_scenario
|
||||
.run_test(
|
||||
&policy,
|
||||
TestExpectations {
|
||||
file_outside_repo_is_writable: true,
|
||||
file_in_repo_root_is_writable: true,
|
||||
file_in_dot_git_dir_is_writable: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Under ReadOnly, writes should not be permitted anywhere on disk.
|
||||
#[tokio::test]
|
||||
async fn read_only_forbids_all_writes() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let test_scenario = create_test_scenario(&tmp);
|
||||
let policy = SandboxPolicy::new_read_only_policy();
|
||||
|
||||
test_scenario
|
||||
.run_test(
|
||||
&policy,
|
||||
TestExpectations {
|
||||
file_outside_repo_is_writable: false,
|
||||
file_in_repo_root_is_writable: false,
|
||||
file_in_dot_git_dir_is_writable: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openpty_works_under_seatbelt() {
|
||||
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
|
||||
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
|
||||
return;
|
||||
}
|
||||
|
||||
if which::which("python3").is_err() {
|
||||
eprintln!("python3 not found in PATH, skipping test.");
|
||||
return;
|
||||
}
|
||||
|
||||
let policy = SandboxPolicy::new_read_only_policy();
|
||||
let command_cwd = AbsolutePathBuf::current_dir().expect("getcwd");
|
||||
let sandbox_cwd = command_cwd.clone();
|
||||
|
||||
let mut child = spawn_command_under_seatbelt(
|
||||
vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
r#"import os
|
||||
|
||||
master, slave = os.openpty()
|
||||
os.write(slave, b"ping")
|
||||
assert os.read(master, 4) == b"ping""#
|
||||
.to_string(),
|
||||
],
|
||||
command_cwd,
|
||||
&policy,
|
||||
&sandbox_cwd,
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
/*network*/ None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("should be able to spawn python under seatbelt");
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.expect("should be able to wait for child process");
|
||||
assert!(status.success(), "python exited with {status:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn java_home_finds_runtime_under_seatbelt() {
|
||||
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
|
||||
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
|
||||
return;
|
||||
}
|
||||
|
||||
let java_home_path = Path::new("/usr/libexec/java_home");
|
||||
if !java_home_path.exists() {
|
||||
eprintln!("/usr/libexec/java_home is not present, skipping test.");
|
||||
return;
|
||||
}
|
||||
|
||||
let baseline_output = tokio::process::Command::new(java_home_path)
|
||||
.env_remove("JAVA_HOME")
|
||||
.output()
|
||||
.await
|
||||
.expect("should be able to invoke java_home outside seatbelt");
|
||||
if !baseline_output.status.success() {
|
||||
eprintln!(
|
||||
"java_home exited with {:?} outside seatbelt, skipping test",
|
||||
baseline_output.status
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let policy = SandboxPolicy::new_read_only_policy();
|
||||
let command_cwd = AbsolutePathBuf::current_dir().expect("getcwd");
|
||||
let sandbox_cwd = command_cwd.clone();
|
||||
|
||||
let mut env: HashMap<String, String> = std::env::vars().collect();
|
||||
env.remove("JAVA_HOME");
|
||||
env.remove(CODEX_SANDBOX_ENV_VAR);
|
||||
|
||||
let child = spawn_command_under_seatbelt(
|
||||
vec![java_home_path.to_string_lossy().to_string()],
|
||||
command_cwd,
|
||||
&policy,
|
||||
&sandbox_cwd,
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
/*network*/ None,
|
||||
env,
|
||||
)
|
||||
.await
|
||||
.expect("should be able to spawn java_home under seatbelt");
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.expect("should be able to wait for java_home child");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"java_home under seatbelt exited with {:?}, stderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
!stdout.trim().is_empty(),
|
||||
"java_home stdout unexpectedly empty under seatbelt"
|
||||
);
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
fn create_test_scenario(tmp: &TempDir) -> TestScenario {
|
||||
let repo_parent = tmp.path().to_path_buf();
|
||||
let repo_root = repo_parent.join("repo");
|
||||
let dot_git_dir = repo_root.join(".git");
|
||||
|
||||
std::fs::create_dir(&repo_root).expect("should be able to create repo root");
|
||||
std::fs::create_dir(&dot_git_dir).expect("should be able to create .git dir");
|
||||
|
||||
TestScenario {
|
||||
file_outside_repo: repo_parent.join("outside.txt"),
|
||||
repo_parent,
|
||||
file_in_repo_root: repo_root.join("repo_file.txt"),
|
||||
repo_root,
|
||||
file_in_dot_git_dir: dot_git_dir.join("dot_git_file.txt"),
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
/// Note that `path` must be absolute.
|
||||
async fn touch(path: &Path, policy: &SandboxPolicy) -> bool {
|
||||
assert!(path.is_absolute(), "Path must be absolute: {path:?}");
|
||||
let command_cwd = AbsolutePathBuf::current_dir().expect("getcwd");
|
||||
let sandbox_cwd = command_cwd.clone();
|
||||
let mut child = spawn_command_under_seatbelt(
|
||||
vec![
|
||||
"/usr/bin/touch".to_string(),
|
||||
path.to_string_lossy().to_string(),
|
||||
],
|
||||
command_cwd,
|
||||
policy,
|
||||
&sandbox_cwd,
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
/*network*/ None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("should be able to spawn command under seatbelt");
|
||||
child
|
||||
.wait()
|
||||
.await
|
||||
.expect("should be able to wait for child process")
|
||||
.success()
|
||||
}
|
||||
@@ -325,12 +325,17 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "call-123";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
"{\"message\":\"ping\"}",
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -331,7 +331,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
|
||||
|
||||
let call_id = "rmcp-truncated";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
// Build a very large message to exceed 10KiB once serialized.
|
||||
let large_msg = "long-message-with-newlines-".repeat(6000);
|
||||
@@ -341,7 +341,12 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
|
||||
&server,
|
||||
sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, &args_json.to_string()),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
&args_json.to_string(),
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -426,13 +431,13 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
|
||||
|
||||
let call_id = "rmcp-image-no-trunc";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__image");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, &tool_name, "{}"),
|
||||
responses::ev_function_call_with_namespace(call_id, &namespace, "image", "{}"),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
@@ -705,7 +710,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
|
||||
|
||||
let call_id = "rmcp-untruncated";
|
||||
let server_name = "rmcp";
|
||||
let tool_name = format!("mcp__{server_name}__echo");
|
||||
let namespace = format!("mcp__{server_name}__");
|
||||
let large_msg = "a".repeat(80_000);
|
||||
let args_json = serde_json::json!({ "message": large_msg });
|
||||
|
||||
@@ -713,7 +718,12 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
|
||||
&server,
|
||||
sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, &tool_name, &args_json.to_string()),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
"echo",
|
||||
&args_json.to_string(),
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user