mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
Merge branch 'main' of github.com:openai/codex into dev/mzeng/codex_apps_mcp_declare
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "codex"
|
||||
|
||||
[setup]
|
||||
script = ""
|
||||
|
||||
[[actions]]
|
||||
name = "Run"
|
||||
icon = "run"
|
||||
command = "cargo +1.93.0 run --manifest-path=codex-rs/Cargo.toml --bin codex -- -c mcp_oauth_credentials_store=file"
|
||||
@@ -17,10 +17,10 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
|
||||
- name: Run cargo-deny
|
||||
uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2
|
||||
with:
|
||||
rust-version: stable
|
||||
rust-version: 1.93.0
|
||||
manifest-path: ./codex-rs/Cargo.toml
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dtolnay/rust-toolchain@c2b55edffaf41a251c410bb32bed22afefa800f1 # 1.92
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
- name: Validate tag matches Cargo.toml version
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -6,4 +6,6 @@ ignore = [
|
||||
"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-0118", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
|
||||
"RUSTSEC-2026-0119", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
|
||||
]
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ jobs:
|
||||
working-directory: codex-rs
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
|
||||
Generated
+40
-7
@@ -1857,8 +1857,8 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"codex-analytics",
|
||||
"codex-api",
|
||||
"codex-app-server-protocol",
|
||||
"codex-app-server-transport",
|
||||
"codex-arg0",
|
||||
"codex-backend-client",
|
||||
"codex-chatgpt",
|
||||
@@ -1891,23 +1891,17 @@ dependencies = [
|
||||
"codex-state",
|
||||
"codex-thread-store",
|
||||
"codex-tools",
|
||||
"codex-uds",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-cli",
|
||||
"codex-utils-json-to-toml",
|
||||
"codex-utils-pty",
|
||||
"codex-utils-rustls-provider",
|
||||
"constant_time_eq 0.3.1",
|
||||
"core_test_support",
|
||||
"flate2",
|
||||
"futures",
|
||||
"gethostname",
|
||||
"hmac",
|
||||
"jsonwebtoken",
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"owo-colors",
|
||||
"pretty_assertions",
|
||||
"reqwest",
|
||||
"rmcp",
|
||||
@@ -2005,6 +1999,45 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-app-server-transport"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
"codex-api",
|
||||
"codex-app-server-protocol",
|
||||
"codex-config",
|
||||
"codex-core",
|
||||
"codex-login",
|
||||
"codex-model-provider",
|
||||
"codex-state",
|
||||
"codex-uds",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-rustls-provider",
|
||||
"constant_time_eq 0.3.1",
|
||||
"futures",
|
||||
"gethostname",
|
||||
"hmac",
|
||||
"jsonwebtoken",
|
||||
"owo-colors",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-apply-patch"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -8,6 +8,7 @@ members = [
|
||||
"ansi-escape",
|
||||
"async-utils",
|
||||
"app-server",
|
||||
"app-server-transport",
|
||||
"app-server-client",
|
||||
"app-server-protocol",
|
||||
"app-server-test-client",
|
||||
@@ -127,6 +128,7 @@ codex-ansi-escape = { path = "ansi-escape" }
|
||||
codex-api = { path = "codex-api" }
|
||||
codex-aws-auth = { path = "aws-auth" }
|
||||
codex-app-server = { path = "app-server" }
|
||||
codex-app-server-transport = { path = "app-server-transport" }
|
||||
codex-app-server-client = { path = "app-server-client" }
|
||||
codex-app-server-protocol = { path = "app-server-protocol" }
|
||||
codex-app-server-test-client = { path = "app-server-test-client" }
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.t
|
||||
|
||||
### Notifications
|
||||
|
||||
You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9.
|
||||
The legacy `notify` setting is deprecated and will be removed in a future release. Existing configurations still work, but new automation should use lifecycle hooks instead. The [notify documentation](../docs/config.md#notify) explains the remaining compatibility behavior. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9.
|
||||
|
||||
### `codex exec` to run Codex programmatically/non-interactively
|
||||
|
||||
|
||||
@@ -300,7 +300,15 @@ impl fmt::Display for TypedRequestError {
|
||||
write!(f, "{method} transport error: {source}")
|
||||
}
|
||||
Self::Server { method, source } => {
|
||||
write!(f, "{method} failed: {}", source.message)
|
||||
write!(
|
||||
f,
|
||||
"{method} failed: {} (code {})",
|
||||
source.message, source.code
|
||||
)?;
|
||||
if let Some(data) = source.data.as_ref() {
|
||||
write!(f, ", data: {data}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::Deserialize { method, source } => {
|
||||
write!(f, "{method} response decode error: {source}")
|
||||
@@ -1915,11 +1923,15 @@ mod tests {
|
||||
method: "thread/read".to_string(),
|
||||
source: JSONRPCErrorError {
|
||||
code: -32603,
|
||||
data: None,
|
||||
data: Some(serde_json::json!({"detail": "config lock mismatch"})),
|
||||
message: "internal".to_string(),
|
||||
},
|
||||
};
|
||||
assert_eq!(std::error::Error::source(&server).is_some(), false);
|
||||
assert_eq!(
|
||||
server.to_string(),
|
||||
"thread/read failed: internal (code -32603), data: {\"detail\":\"config lock mismatch\"}"
|
||||
);
|
||||
|
||||
let deserialize = TypedRequestError::Deserialize {
|
||||
method: "thread/start".to_string(),
|
||||
|
||||
@@ -2217,6 +2217,25 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSkillReadParams": {
|
||||
"properties": {
|
||||
"remoteMarketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"skillName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remoteMarketplaceName",
|
||||
"remotePluginId",
|
||||
"skillName"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginUninstallParams": {
|
||||
"properties": {
|
||||
"pluginId": {
|
||||
@@ -5036,6 +5055,30 @@
|
||||
"title": "Plugin/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/skill/read"
|
||||
],
|
||||
"title": "Plugin/skill/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginSkillReadParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/skill/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
+84
-1
@@ -762,6 +762,30 @@
|
||||
"title": "Plugin/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/skill/read"
|
||||
],
|
||||
"title": "Plugin/skill/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/PluginSkillReadParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/skill/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -12334,6 +12358,31 @@
|
||||
"title": "PluginShareDeleteResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareListItem": {
|
||||
"properties": {
|
||||
"localPluginPath": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugin": {
|
||||
"$ref": "#/definitions/v2/PluginSummary"
|
||||
},
|
||||
"shareUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugin",
|
||||
"shareUrl"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareListParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PluginShareListParams",
|
||||
@@ -12344,7 +12393,7 @@
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/PluginSummary"
|
||||
"$ref": "#/definitions/v2/PluginShareListItem"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
@@ -12391,6 +12440,40 @@
|
||||
"title": "PluginShareSaveResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSkillReadParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"remoteMarketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"skillName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remoteMarketplaceName",
|
||||
"remotePluginId",
|
||||
"skillName"
|
||||
],
|
||||
"title": "PluginSkillReadParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSkillReadResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"contents": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginSkillReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
+84
-1
@@ -1521,6 +1521,30 @@
|
||||
"title": "Plugin/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/skill/read"
|
||||
],
|
||||
"title": "Plugin/skill/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginSkillReadParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/skill/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -8987,6 +9011,31 @@
|
||||
"title": "PluginShareDeleteResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareListItem": {
|
||||
"properties": {
|
||||
"localPluginPath": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugin": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
},
|
||||
"shareUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugin",
|
||||
"shareUrl"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareListParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PluginShareListParams",
|
||||
@@ -8997,7 +9046,7 @@
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
"$ref": "#/definitions/PluginShareListItem"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
@@ -9044,6 +9093,40 @@
|
||||
"title": "PluginShareSaveResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSkillReadParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"remoteMarketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"skillName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remoteMarketplaceName",
|
||||
"remotePluginId",
|
||||
"skillName"
|
||||
],
|
||||
"title": "PluginSkillReadParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSkillReadResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"contents": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginSkillReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -167,6 +167,31 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareListItem": {
|
||||
"properties": {
|
||||
"localPluginPath": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugin": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
},
|
||||
"shareUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugin",
|
||||
"shareUrl"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -304,7 +329,7 @@
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
"$ref": "#/definitions/PluginShareListItem"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"remoteMarketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"skillName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remoteMarketplaceName",
|
||||
"remotePluginId",
|
||||
"skillName"
|
||||
],
|
||||
"title": "PluginSkillReadParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"contents": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginSkillReadResponse",
|
||||
"type": "object"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// 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 { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
import type { PluginSummary } from "./PluginSummary";
|
||||
|
||||
export type PluginShareListItem = { plugin: PluginSummary, shareUrl: string, localPluginPath: AbsolutePathBuf | null, };
|
||||
+2
-2
@@ -1,6 +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 { PluginSummary } from "./PluginSummary";
|
||||
import type { PluginShareListItem } from "./PluginShareListItem";
|
||||
|
||||
export type PluginShareListResponse = { data: Array<PluginSummary>, };
|
||||
export type PluginShareListResponse = { data: Array<PluginShareListItem>, };
|
||||
|
||||
@@ -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 PluginSkillReadParams = { remoteMarketplaceName: string, remotePluginId: string, skillName: string, };
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PluginSkillReadResponse = { contents: string | null, };
|
||||
@@ -283,10 +283,13 @@ export type { PluginReadParams } from "./PluginReadParams";
|
||||
export type { PluginReadResponse } from "./PluginReadResponse";
|
||||
export type { PluginShareDeleteParams } from "./PluginShareDeleteParams";
|
||||
export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse";
|
||||
export type { PluginShareListItem } from "./PluginShareListItem";
|
||||
export type { PluginShareListParams } from "./PluginShareListParams";
|
||||
export type { PluginShareListResponse } from "./PluginShareListResponse";
|
||||
export type { PluginShareSaveParams } from "./PluginShareSaveParams";
|
||||
export type { PluginShareSaveResponse } from "./PluginShareSaveResponse";
|
||||
export type { PluginSkillReadParams } from "./PluginSkillReadParams";
|
||||
export type { PluginSkillReadResponse } from "./PluginSkillReadResponse";
|
||||
export type { PluginSource } from "./PluginSource";
|
||||
export type { PluginSummary } from "./PluginSummary";
|
||||
export type { PluginUninstallParams } from "./PluginUninstallParams";
|
||||
|
||||
@@ -612,6 +612,11 @@ client_request_definitions! {
|
||||
serialization: global("config"),
|
||||
response: v2::PluginReadResponse,
|
||||
},
|
||||
PluginSkillRead => "plugin/skill/read" {
|
||||
params: v2::PluginSkillReadParams,
|
||||
serialization: global("config"),
|
||||
response: v2::PluginSkillReadResponse,
|
||||
},
|
||||
PluginShareSave => "plugin/share/save" {
|
||||
params: v2::PluginShareSaveParams,
|
||||
serialization: global("config"),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::protocol::common::ServerNotification;
|
||||
use crate::protocol::item_builders::build_command_execution_begin_item;
|
||||
use crate::protocol::item_builders::build_command_execution_end_item;
|
||||
use crate::protocol::item_builders::build_file_change_begin_item;
|
||||
use crate::protocol::item_builders::convert_patch_changes;
|
||||
use crate::protocol::v2::AgentMessageDeltaNotification;
|
||||
use crate::protocol::v2::CollabAgentState;
|
||||
@@ -450,13 +449,6 @@ pub fn item_event_to_server_notification(
|
||||
item: item_completed_event.item.into(),
|
||||
})
|
||||
}
|
||||
EventMsg::PatchApplyBegin(patch_begin_event) => {
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item: build_file_change_begin_item(&patch_begin_event),
|
||||
})
|
||||
}
|
||||
EventMsg::PatchApplyUpdated(event) => {
|
||||
ServerNotification::FileChangePatchUpdated(FileChangePatchUpdatedNotification {
|
||||
thread_id,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Shared builders for synthetic [`ThreadItem`] values emitted by the app-server layer.
|
||||
//! Shared builders for app-server [`ThreadItem`] values derived from compatibility events.
|
||||
//!
|
||||
//! These items do not come from first-class core `ItemStarted` / `ItemCompleted` events.
|
||||
//! Instead, the app-server synthesizes them so clients can render a coherent lifecycle for
|
||||
//! approvals and other pre-execution flows before the underlying tool has started or when the
|
||||
//! tool never starts at all.
|
||||
//! Most live tool items now come from first-class core `ItemStarted` / `ItemCompleted` events.
|
||||
//! These builders remain for approval flows, rebuilt legacy history, and other pre-execution
|
||||
//! paths where the underlying tool has not started or never starts at all.
|
||||
//!
|
||||
//! Keeping these builders in one place is useful for two reasons:
|
||||
//! - Live notifications and rebuilt `thread/read` history both need to construct the same
|
||||
|
||||
@@ -356,7 +356,9 @@ impl ThreadHistoryBuilder {
|
||||
| codex_protocol::items::TurnItem::AgentMessage(_)
|
||||
| codex_protocol::items::TurnItem::Reasoning(_)
|
||||
| codex_protocol::items::TurnItem::WebSearch(_)
|
||||
| codex_protocol::items::TurnItem::ImageView(_)
|
||||
| codex_protocol::items::TurnItem::ImageGeneration(_)
|
||||
| codex_protocol::items::TurnItem::FileChange(_)
|
||||
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -377,7 +379,9 @@ impl ThreadHistoryBuilder {
|
||||
| codex_protocol::items::TurnItem::AgentMessage(_)
|
||||
| codex_protocol::items::TurnItem::Reasoning(_)
|
||||
| codex_protocol::items::TurnItem::WebSearch(_)
|
||||
| codex_protocol::items::TurnItem::ImageView(_)
|
||||
| codex_protocol::items::TurnItem::ImageGeneration(_)
|
||||
| codex_protocol::items::TurnItem::FileChange(_)
|
||||
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::RequestId;
|
||||
use crate::protocol::common::AuthMode;
|
||||
use crate::protocol::item_builders::convert_patch_changes;
|
||||
use codex_experimental_api_macros::ExperimentalApi;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::account::ProviderAccount;
|
||||
@@ -4609,6 +4610,22 @@ pub struct PluginReadResponse {
|
||||
pub plugin: PluginDetail,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginSkillReadParams {
|
||||
pub remote_marketplace_name: String,
|
||||
pub remote_plugin_id: String,
|
||||
pub skill_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginSkillReadResponse {
|
||||
pub contents: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -4635,7 +4652,7 @@ pub struct PluginShareListParams {}
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginShareListResponse {
|
||||
pub data: Vec<PluginSummary>,
|
||||
pub data: Vec<PluginShareListItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -4650,6 +4667,15 @@ pub struct PluginShareDeleteParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginShareDeleteResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginShareListItem {
|
||||
pub plugin: PluginSummary,
|
||||
pub share_url: String,
|
||||
pub local_plugin_path: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
@@ -6437,6 +6463,10 @@ impl From<CoreTurnItem> for ThreadItem {
|
||||
query: search.query,
|
||||
action: Some(WebSearchAction::from(search.action)),
|
||||
},
|
||||
CoreTurnItem::ImageView(image) => ThreadItem::ImageView {
|
||||
id: image.id,
|
||||
path: image.path,
|
||||
},
|
||||
CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration {
|
||||
id: image.id,
|
||||
status: image.status,
|
||||
@@ -6444,6 +6474,15 @@ impl From<CoreTurnItem> for ThreadItem {
|
||||
result: image.result,
|
||||
saved_path: image.saved_path,
|
||||
},
|
||||
CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange {
|
||||
id: file_change.id,
|
||||
changes: convert_patch_changes(&file_change.changes),
|
||||
status: file_change
|
||||
.status
|
||||
.as_ref()
|
||||
.map(PatchApplyStatus::from)
|
||||
.unwrap_or(PatchApplyStatus::InProgress),
|
||||
},
|
||||
CoreTurnItem::ContextCompaction(compaction) => {
|
||||
ThreadItem::ContextCompaction { id: compaction.id }
|
||||
}
|
||||
@@ -8053,6 +8092,8 @@ mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::items::AgentMessageContent;
|
||||
use codex_protocol::items::AgentMessageItem;
|
||||
use codex_protocol::items::FileChangeItem;
|
||||
use codex_protocol::items::ImageViewItem;
|
||||
use codex_protocol::items::ReasoningItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
@@ -10333,6 +10374,48 @@ mod tests {
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
let image_view_item = TurnItem::ImageView(ImageViewItem {
|
||||
id: "view-image-1".to_string(),
|
||||
path: test_path_buf("/tmp/view-image.png").abs(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ThreadItem::from(image_view_item),
|
||||
ThreadItem::ImageView {
|
||||
id: "view-image-1".to_string(),
|
||||
path: test_path_buf("/tmp/view-image.png").abs(),
|
||||
}
|
||||
);
|
||||
|
||||
let file_change_item = TurnItem::FileChange(FileChangeItem {
|
||||
id: "patch-1".to_string(),
|
||||
changes: [(
|
||||
PathBuf::from("README.md"),
|
||||
codex_protocol::protocol::FileChange::Add {
|
||||
content: "hello\n".to_string(),
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
status: Some(codex_protocol::protocol::PatchApplyStatus::Completed),
|
||||
auto_approved: None,
|
||||
stdout: Some("Done!".to_string()),
|
||||
stderr: Some(String::new()),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ThreadItem::from(file_change_item),
|
||||
ThreadItem::FileChange {
|
||||
id: "patch-1".to_string(),
|
||||
changes: vec![FileUpdateChange {
|
||||
path: "README.md".to_string(),
|
||||
kind: PatchChangeKind::Add,
|
||||
diff: "hello\n".to_string(),
|
||||
}],
|
||||
status: PatchApplyStatus::Completed,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -10667,6 +10750,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_skill_read_params_serialization_uses_remote_plugin_id() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginSkillReadParams {
|
||||
remote_marketplace_name: "chatgpt-global".to_string(),
|
||||
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
skill_name: "plan-work".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"remoteMarketplaceName": "chatgpt-global",
|
||||
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"skillName": "plan-work",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
|
||||
let plugin_path = if cfg!(windows) {
|
||||
@@ -10732,33 +10832,41 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_share_list_response_serializes_plugin_summaries() {
|
||||
fn plugin_share_list_response_serializes_share_items() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginShareListResponse {
|
||||
data: vec![PluginSummary {
|
||||
id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
name: "gmail".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: None,
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
name: "gmail".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: None,
|
||||
},
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
local_plugin_path: None,
|
||||
}],
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"data": [{
|
||||
"id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"name": "gmail",
|
||||
"source": { "type": "remote" },
|
||||
"installed": false,
|
||||
"enabled": false,
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_USE",
|
||||
"availability": "AVAILABLE",
|
||||
"interface": null,
|
||||
"plugin": {
|
||||
"id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"name": "gmail",
|
||||
"source": { "type": "remote" },
|
||||
"installed": false,
|
||||
"enabled": false,
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_USE",
|
||||
"availability": "AVAILABLE",
|
||||
"interface": null,
|
||||
},
|
||||
"shareUrl": "https://chatgpt.example/plugins/share/share-key-1",
|
||||
"localPluginPath": null,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "app-server-transport",
|
||||
crate_name = "codex_app_server_transport",
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
[package]
|
||||
name = "codex-app-server-transport"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_app_server_transport"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
axum = { workspace = true, default-features = false, features = [
|
||||
"http1",
|
||||
"json",
|
||||
"tokio",
|
||||
"ws",
|
||||
] }
|
||||
base64 = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-api = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-uds = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-rustls-provider = { workspace = true }
|
||||
constant_time_eq = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
gethostname = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
owo-colors = { workspace = true, features = ["supports-colors"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
time = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
url = { workspace = true }
|
||||
uuid = { workspace = true, features = ["serde", "v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
@@ -0,0 +1,20 @@
|
||||
mod outgoing_message;
|
||||
mod transport;
|
||||
|
||||
pub use outgoing_message::ConnectionId;
|
||||
pub use outgoing_message::OutgoingError;
|
||||
pub use outgoing_message::OutgoingMessage;
|
||||
pub use outgoing_message::OutgoingResponse;
|
||||
pub use outgoing_message::QueuedOutgoingMessage;
|
||||
pub use transport::AppServerTransport;
|
||||
pub use transport::AppServerTransportParseError;
|
||||
pub use transport::CHANNEL_CAPACITY;
|
||||
pub use transport::ConnectionOrigin;
|
||||
pub use transport::RemoteControlHandle;
|
||||
pub use transport::TransportEvent;
|
||||
pub use transport::app_server_control_socket_path;
|
||||
pub use transport::auth;
|
||||
pub use transport::start_control_socket_acceptor;
|
||||
pub use transport::start_remote_control;
|
||||
pub use transport::start_stdio_connection;
|
||||
pub use transport::start_websocket_acceptor;
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::fmt;
|
||||
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::Result;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// Stable identifier for a transport connection.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ConnectionId(pub u64);
|
||||
|
||||
impl fmt::Display for ConnectionId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Outgoing message from the server to the client.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum OutgoingMessage {
|
||||
Request(ServerRequest),
|
||||
/// AppServerNotification is specific to the case where this is run as an
|
||||
/// "app server" as opposed to an MCP server.
|
||||
AppServerNotification(ServerNotification),
|
||||
Response(OutgoingResponse),
|
||||
Error(OutgoingError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct OutgoingResponse {
|
||||
pub id: RequestId,
|
||||
pub result: Result,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct OutgoingError {
|
||||
pub error: JSONRPCErrorError,
|
||||
pub id: RequestId,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct QueuedOutgoingMessage {
|
||||
pub message: OutgoingMessage,
|
||||
pub write_complete_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl QueuedOutgoingMessage {
|
||||
pub fn new(message: OutgoingMessage) -> Self {
|
||||
Self {
|
||||
message,
|
||||
write_complete_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -86,7 +86,7 @@ pub enum AppServerWebsocketCapabilityTokenSource {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct WebsocketAuthPolicy {
|
||||
pub struct WebsocketAuthPolicy {
|
||||
pub(crate) mode: Option<WebsocketAuthMode>,
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ impl AppServerWebsocketAuthArgs {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn policy_from_settings(
|
||||
pub fn policy_from_settings(
|
||||
settings: &AppServerWebsocketAuthSettings,
|
||||
) -> io::Result<WebsocketAuthPolicy> {
|
||||
let mode = match settings.config.as_ref() {
|
||||
@@ -0,0 +1,478 @@
|
||||
pub mod auth;
|
||||
|
||||
use crate::outgoing_message::ConnectionId;
|
||||
use crate::outgoing_message::OutgoingError;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
use crate::outgoing_message::QueuedOutgoingMessage;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::error;
|
||||
use tracing::warn;
|
||||
|
||||
/// Size of the bounded channels used to communicate between tasks. The value
|
||||
/// is a balance between throughput and memory usage - 128 messages should be
|
||||
/// plenty for an interactive CLI.
|
||||
pub const CHANNEL_CAPACITY: usize = 128;
|
||||
|
||||
mod remote_control;
|
||||
mod stdio;
|
||||
mod unix_socket;
|
||||
#[cfg(test)]
|
||||
mod unix_socket_tests;
|
||||
mod websocket;
|
||||
|
||||
pub use remote_control::RemoteControlHandle;
|
||||
pub use remote_control::start_remote_control;
|
||||
pub use stdio::start_stdio_connection;
|
||||
pub use unix_socket::start_control_socket_acceptor;
|
||||
pub use websocket::start_websocket_acceptor;
|
||||
|
||||
const OVERLOADED_ERROR_CODE: i64 = -32001;
|
||||
|
||||
const APP_SERVER_CONTROL_SOCKET_DIR_NAME: &str = "app-server-control";
|
||||
const APP_SERVER_CONTROL_SOCKET_FILE_NAME: &str = "app-server-control.sock";
|
||||
|
||||
pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result<AbsolutePathBuf> {
|
||||
AbsolutePathBuf::from_absolute_path(
|
||||
codex_home
|
||||
.join(APP_SERVER_CONTROL_SOCKET_DIR_NAME)
|
||||
.join(APP_SERVER_CONTROL_SOCKET_FILE_NAME),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum AppServerTransport {
|
||||
Stdio,
|
||||
UnixSocket { socket_path: AbsolutePathBuf },
|
||||
WebSocket { bind_address: SocketAddr },
|
||||
Off,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum AppServerTransportParseError {
|
||||
UnsupportedListenUrl(String),
|
||||
InvalidUnixSocketPath { listen_url: String, message: String },
|
||||
InvalidWebSocketListenUrl(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppServerTransportParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AppServerTransportParseError::UnsupportedListenUrl(listen_url) => write!(
|
||||
f,
|
||||
"unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, `ws://IP:PORT`, or `off`"
|
||||
),
|
||||
AppServerTransportParseError::InvalidUnixSocketPath {
|
||||
listen_url,
|
||||
message,
|
||||
} => write!(
|
||||
f,
|
||||
"invalid unix socket --listen URL `{listen_url}`; failed to resolve socket path: {message}"
|
||||
),
|
||||
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!(
|
||||
f,
|
||||
"invalid websocket --listen URL `{listen_url}`; expected `ws://IP:PORT`"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AppServerTransportParseError {}
|
||||
|
||||
impl AppServerTransport {
|
||||
pub const DEFAULT_LISTEN_URL: &'static str = "stdio://";
|
||||
|
||||
pub fn from_listen_url(listen_url: &str) -> Result<Self, AppServerTransportParseError> {
|
||||
if listen_url == Self::DEFAULT_LISTEN_URL {
|
||||
return Ok(Self::Stdio);
|
||||
}
|
||||
|
||||
if let Some(raw_socket_path) = listen_url.strip_prefix("unix://") {
|
||||
let socket_path = if raw_socket_path.is_empty() {
|
||||
let codex_home = find_codex_home().map_err(|err| {
|
||||
AppServerTransportParseError::InvalidUnixSocketPath {
|
||||
listen_url: listen_url.to_string(),
|
||||
message: format!("failed to resolve CODEX_HOME: {err}"),
|
||||
}
|
||||
})?;
|
||||
app_server_control_socket_path(&codex_home).map_err(|err| {
|
||||
AppServerTransportParseError::InvalidUnixSocketPath {
|
||||
listen_url: listen_url.to_string(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
})?
|
||||
} else {
|
||||
AbsolutePathBuf::relative_to_current_dir(raw_socket_path).map_err(|err| {
|
||||
AppServerTransportParseError::InvalidUnixSocketPath {
|
||||
listen_url: listen_url.to_string(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
})?
|
||||
};
|
||||
return Ok(Self::UnixSocket { socket_path });
|
||||
}
|
||||
|
||||
if listen_url == "off" {
|
||||
return Ok(Self::Off);
|
||||
}
|
||||
|
||||
if let Some(socket_addr) = listen_url.strip_prefix("ws://") {
|
||||
let bind_address = socket_addr.parse::<SocketAddr>().map_err(|_| {
|
||||
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string())
|
||||
})?;
|
||||
return Ok(Self::WebSocket { bind_address });
|
||||
}
|
||||
|
||||
Err(AppServerTransportParseError::UnsupportedListenUrl(
|
||||
listen_url.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AppServerTransport {
|
||||
type Err = AppServerTransportParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::from_listen_url(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TransportEvent {
|
||||
ConnectionOpened {
|
||||
connection_id: ConnectionId,
|
||||
origin: ConnectionOrigin,
|
||||
writer: mpsc::Sender<QueuedOutgoingMessage>,
|
||||
disconnect_sender: Option<CancellationToken>,
|
||||
},
|
||||
ConnectionClosed {
|
||||
connection_id: ConnectionId,
|
||||
},
|
||||
IncomingMessage {
|
||||
connection_id: ConnectionId,
|
||||
message: JSONRPCMessage,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnectionOrigin {
|
||||
Stdio,
|
||||
InProcess,
|
||||
WebSocket,
|
||||
RemoteControl,
|
||||
}
|
||||
|
||||
impl ConnectionOrigin {
|
||||
pub fn allows_device_key_requests(self) -> bool {
|
||||
// Device-key endpoints are only for local connections that own the app-server instance.
|
||||
// Do not include remote transports such as SSH or remote-control websocket connections.
|
||||
matches!(self, Self::Stdio | Self::InProcess)
|
||||
}
|
||||
}
|
||||
|
||||
static CONNECTION_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn next_connection_id() -> ConnectionId {
|
||||
ConnectionId(CONNECTION_ID_COUNTER.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
async fn forward_incoming_message(
|
||||
transport_event_tx: &mpsc::Sender<TransportEvent>,
|
||||
writer: &mpsc::Sender<QueuedOutgoingMessage>,
|
||||
connection_id: ConnectionId,
|
||||
payload: &str,
|
||||
) -> bool {
|
||||
match serde_json::from_str::<JSONRPCMessage>(payload) {
|
||||
Ok(message) => {
|
||||
enqueue_incoming_message(transport_event_tx, writer, connection_id, message).await
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to deserialize JSONRPCMessage: {err}");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn enqueue_incoming_message(
|
||||
transport_event_tx: &mpsc::Sender<TransportEvent>,
|
||||
writer: &mpsc::Sender<QueuedOutgoingMessage>,
|
||||
connection_id: ConnectionId,
|
||||
message: JSONRPCMessage,
|
||||
) -> bool {
|
||||
let event = TransportEvent::IncomingMessage {
|
||||
connection_id,
|
||||
message,
|
||||
};
|
||||
match transport_event_tx.try_send(event) {
|
||||
Ok(()) => true,
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => false,
|
||||
Err(mpsc::error::TrySendError::Full(TransportEvent::IncomingMessage {
|
||||
connection_id,
|
||||
message: JSONRPCMessage::Request(request),
|
||||
})) => {
|
||||
let overload_error = OutgoingMessage::Error(OutgoingError {
|
||||
id: request.id,
|
||||
error: JSONRPCErrorError {
|
||||
code: OVERLOADED_ERROR_CODE,
|
||||
message: "Server overloaded; retry later.".to_string(),
|
||||
data: None,
|
||||
},
|
||||
});
|
||||
match writer.try_send(QueuedOutgoingMessage::new(overload_error)) {
|
||||
Ok(()) => true,
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => false,
|
||||
Err(mpsc::error::TrySendError::Full(_overload_error)) => {
|
||||
warn!(
|
||||
"dropping overload response for connection {:?}: outbound queue is full",
|
||||
connection_id
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(event)) => transport_event_tx.send(event).await.is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_outgoing_message(outgoing_message: OutgoingMessage) -> Option<String> {
|
||||
let value = match serde_json::to_value(outgoing_message) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
error!("Failed to convert OutgoingMessage to JSON value: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match serde_json::to_string(&value) {
|
||||
Ok(json) => Some(json),
|
||||
Err(err) => {
|
||||
error!("Failed to serialize JSONRPCMessage: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[test]
|
||||
fn listen_off_parses_as_off_transport() {
|
||||
assert_eq!(
|
||||
AppServerTransport::from_listen_url("off"),
|
||||
Ok(AppServerTransport::Off)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_incoming_request_returns_overload_error_when_queue_is_full() {
|
||||
let connection_id = ConnectionId(42);
|
||||
let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
|
||||
let first_message = JSONRPCMessage::Notification(JSONRPCNotification {
|
||||
method: "initialized".to_string(),
|
||||
params: None,
|
||||
});
|
||||
transport_event_tx
|
||||
.send(TransportEvent::IncomingMessage {
|
||||
connection_id,
|
||||
message: first_message.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("queue should accept first message");
|
||||
|
||||
let request = JSONRPCMessage::Request(JSONRPCRequest {
|
||||
id: RequestId::Integer(7),
|
||||
method: "config/read".to_string(),
|
||||
params: Some(json!({ "includeLayers": false })),
|
||||
trace: None,
|
||||
});
|
||||
assert!(
|
||||
enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request).await
|
||||
);
|
||||
|
||||
let queued_event = transport_event_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("first event should stay queued");
|
||||
match queued_event {
|
||||
TransportEvent::IncomingMessage {
|
||||
connection_id: queued_connection_id,
|
||||
message,
|
||||
} => {
|
||||
assert_eq!(queued_connection_id, connection_id);
|
||||
assert_eq!(message, first_message);
|
||||
}
|
||||
_ => panic!("expected queued incoming message"),
|
||||
}
|
||||
|
||||
let overload = writer_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("request should receive overload error");
|
||||
let overload_json =
|
||||
serde_json::to_value(overload.message).expect("serialize overload error");
|
||||
assert_eq!(
|
||||
overload_json,
|
||||
json!({
|
||||
"id": 7,
|
||||
"error": {
|
||||
"code": OVERLOADED_ERROR_CODE,
|
||||
"message": "Server overloaded; retry later."
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_incoming_response_waits_instead_of_dropping_when_queue_is_full() {
|
||||
let connection_id = ConnectionId(42);
|
||||
let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1);
|
||||
let (writer_tx, _writer_rx) = mpsc::channel(1);
|
||||
|
||||
let first_message = JSONRPCMessage::Notification(JSONRPCNotification {
|
||||
method: "initialized".to_string(),
|
||||
params: None,
|
||||
});
|
||||
transport_event_tx
|
||||
.send(TransportEvent::IncomingMessage {
|
||||
connection_id,
|
||||
message: first_message.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("queue should accept first message");
|
||||
|
||||
let response = JSONRPCMessage::Response(JSONRPCResponse {
|
||||
id: RequestId::Integer(7),
|
||||
result: json!({"ok": true}),
|
||||
});
|
||||
let transport_event_tx_for_enqueue = transport_event_tx.clone();
|
||||
let writer_tx_for_enqueue = writer_tx.clone();
|
||||
let enqueue_handle = tokio::spawn(async move {
|
||||
enqueue_incoming_message(
|
||||
&transport_event_tx_for_enqueue,
|
||||
&writer_tx_for_enqueue,
|
||||
connection_id,
|
||||
response,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
let queued_event = transport_event_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("first event should be dequeued");
|
||||
match queued_event {
|
||||
TransportEvent::IncomingMessage {
|
||||
connection_id: queued_connection_id,
|
||||
message,
|
||||
} => {
|
||||
assert_eq!(queued_connection_id, connection_id);
|
||||
assert_eq!(message, first_message);
|
||||
}
|
||||
_ => panic!("expected queued incoming message"),
|
||||
}
|
||||
|
||||
let enqueue_result = enqueue_handle.await.expect("enqueue task should not panic");
|
||||
assert!(enqueue_result);
|
||||
|
||||
let forwarded_event = transport_event_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("response should be forwarded instead of dropped");
|
||||
match forwarded_event {
|
||||
TransportEvent::IncomingMessage {
|
||||
connection_id: queued_connection_id,
|
||||
message: JSONRPCMessage::Response(JSONRPCResponse { id, result }),
|
||||
} => {
|
||||
assert_eq!(queued_connection_id, connection_id);
|
||||
assert_eq!(id, RequestId::Integer(7));
|
||||
assert_eq!(result, json!({"ok": true}));
|
||||
}
|
||||
_ => panic!("expected forwarded response message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_incoming_request_does_not_block_when_writer_queue_is_full() {
|
||||
let connection_id = ConnectionId(42);
|
||||
let (transport_event_tx, _transport_event_rx) = mpsc::channel(1);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
|
||||
transport_event_tx
|
||||
.send(TransportEvent::IncomingMessage {
|
||||
connection_id,
|
||||
message: JSONRPCMessage::Notification(JSONRPCNotification {
|
||||
method: "initialized".to_string(),
|
||||
params: None,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("transport queue should accept first message");
|
||||
|
||||
writer_tx
|
||||
.send(QueuedOutgoingMessage::new(
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification {
|
||||
summary: "queued".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
},
|
||||
)),
|
||||
))
|
||||
.await
|
||||
.expect("writer queue should accept first message");
|
||||
|
||||
let request = JSONRPCMessage::Request(JSONRPCRequest {
|
||||
id: RequestId::Integer(7),
|
||||
method: "config/read".to_string(),
|
||||
params: Some(json!({ "includeLayers": false })),
|
||||
trace: None,
|
||||
});
|
||||
|
||||
let enqueue_result = timeout(
|
||||
Duration::from_millis(100),
|
||||
enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request),
|
||||
)
|
||||
.await
|
||||
.expect("enqueue should not block while writer queue is full");
|
||||
assert!(enqueue_result);
|
||||
|
||||
let queued_outgoing = writer_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("writer queue should still contain original message");
|
||||
let queued_json =
|
||||
serde_json::to_value(queued_outgoing.message).expect("serialize queued message");
|
||||
assert_eq!(
|
||||
queued_json,
|
||||
json!({
|
||||
"method": "configWarning",
|
||||
"params": {
|
||||
"summary": "queued",
|
||||
"details": null,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-6
@@ -36,14 +36,14 @@ pub(super) struct QueuedServerEnvelope {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RemoteControlHandle {
|
||||
pub struct RemoteControlHandle {
|
||||
enabled_tx: Arc<watch::Sender<bool>>,
|
||||
status_tx: Arc<watch::Sender<RemoteControlStatusChangedNotification>>,
|
||||
state_db_available: bool,
|
||||
}
|
||||
|
||||
impl RemoteControlHandle {
|
||||
pub(crate) fn set_enabled(&self, enabled: bool) {
|
||||
pub fn set_enabled(&self, enabled: bool) {
|
||||
let requested_enabled = enabled;
|
||||
let enabled = enabled && self.state_db_available;
|
||||
if requested_enabled && !self.state_db_available {
|
||||
@@ -56,14 +56,12 @@ impl RemoteControlHandle {
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn status_receiver(
|
||||
&self,
|
||||
) -> watch::Receiver<RemoteControlStatusChangedNotification> {
|
||||
pub fn status_receiver(&self) -> watch::Receiver<RemoteControlStatusChangedNotification> {
|
||||
self.status_tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn start_remote_control(
|
||||
pub async fn start_remote_control(
|
||||
remote_control_url: String,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
+1
-1
@@ -21,7 +21,7 @@ use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
|
||||
pub(crate) async fn start_stdio_connection(
|
||||
pub async fn start_stdio_connection(
|
||||
transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
stdio_handles: &mut Vec<JoinHandle<()>>,
|
||||
initialize_client_name_tx: oneshot::Sender<String>,
|
||||
+1
-1
@@ -20,7 +20,7 @@ use tracing::warn;
|
||||
#[cfg(unix)]
|
||||
const CONTROL_SOCKET_MODE: u32 = 0o600;
|
||||
|
||||
pub(crate) async fn start_control_socket_acceptor(
|
||||
pub async fn start_control_socket_acceptor(
|
||||
socket_path: AbsolutePathBuf,
|
||||
transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
shutdown_token: CancellationToken,
|
||||
+1
-1
@@ -128,7 +128,7 @@ async fn websocket_upgrade_handler(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(crate) async fn start_websocket_acceptor(
|
||||
pub async fn start_websocket_acceptor(
|
||||
bind_address: SocketAddr,
|
||||
transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
shutdown_token: CancellationToken,
|
||||
@@ -30,7 +30,6 @@ axum = { workspace = true, default-features = false, features = [
|
||||
"ws",
|
||||
] }
|
||||
codex-analytics = { workspace = true }
|
||||
codex-api = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-cloud-requirements = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
@@ -58,6 +57,7 @@ codex-model-provider = { workspace = true }
|
||||
codex-models-manager = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-app-server-transport = { workspace = true }
|
||||
codex-feedback = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-rollout = { workspace = true }
|
||||
@@ -65,18 +65,11 @@ codex-sandboxing = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-thread-store = { workspace = true }
|
||||
codex-tools = { workspace = true }
|
||||
codex-uds = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-json-to-toml = { workspace = true }
|
||||
codex-utils-rustls-provider = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
constant_time_eq = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
gethostname = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
owo-colors = { workspace = true, features = ["supports-colors"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
@@ -93,7 +86,6 @@ tokio = { workspace = true, features = [
|
||||
"signal",
|
||||
] }
|
||||
tokio-util = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] }
|
||||
url = { workspace = true }
|
||||
@@ -111,6 +103,7 @@ core_test_support = { workspace = true }
|
||||
codex-model-provider-info = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
opentelemetry = { workspace = true }
|
||||
opentelemetry_sdk = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -203,6 +203,7 @@ Example with notification opt-out:
|
||||
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
|
||||
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
- `app/list` — list available apps.
|
||||
- `device/key/create` — create or load a controller-local device signing key for an account/client binding. This local-key API is available only over local transports such as stdio and in-process; remote transports reject it. Hardware-backed providers are the target protection class; an OS-protected non-extractable fallback is allowed only with `protectionPolicy: "allow_os_protected_nonextractable"` and returns the reported `protectionClass`.
|
||||
|
||||
@@ -29,7 +29,6 @@ use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment;
|
||||
use codex_app_server_protocol::FileChangeApprovalDecision;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalParams;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
|
||||
use codex_app_server_protocol::FileUpdateChange;
|
||||
use codex_app_server_protocol::GrantedPermissionProfile as V2GrantedPermissionProfile;
|
||||
use codex_app_server_protocol::GuardianWarningNotification;
|
||||
use codex_app_server_protocol::HookCompletedNotification;
|
||||
@@ -46,7 +45,6 @@ use codex_app_server_protocol::ModelVerificationNotification;
|
||||
use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext;
|
||||
use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment;
|
||||
use codex_app_server_protocol::NetworkPolicyRuleAction as V2NetworkPolicyRuleAction;
|
||||
use codex_app_server_protocol::PatchApplyStatus;
|
||||
use codex_app_server_protocol::PermissionsRequestApprovalParams;
|
||||
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
|
||||
use codex_app_server_protocol::RawResponseItemCompletedNotification;
|
||||
@@ -82,11 +80,8 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::WarningNotification;
|
||||
use codex_app_server_protocol::build_file_change_approval_request_item;
|
||||
use codex_app_server_protocol::build_file_change_end_item;
|
||||
use codex_app_server_protocol::build_item_from_guardian_event;
|
||||
use codex_app_server_protocol::build_turns_from_rollout_items;
|
||||
use codex_app_server_protocol::convert_patch_changes;
|
||||
use codex_app_server_protocol::guardian_auto_approval_review_notification;
|
||||
use codex_app_server_protocol::item_event_to_server_notification;
|
||||
use codex_core::CodexThread;
|
||||
@@ -524,28 +519,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
let permission_guard = thread_watch_manager
|
||||
.note_permission_requested(&conversation_id.to_string())
|
||||
.await;
|
||||
// Until we migrate the core to be aware of a first class FileChangeItem
|
||||
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
|
||||
let item_id = event.call_id.clone();
|
||||
let patch_changes = convert_patch_changes(&event.changes);
|
||||
let first_start = {
|
||||
let mut state = thread_state.lock().await;
|
||||
state
|
||||
.turn_summary
|
||||
.file_change_started
|
||||
.insert(item_id.clone())
|
||||
};
|
||||
if first_start {
|
||||
let item = build_file_change_approval_request_item(&event);
|
||||
let notification = ItemStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemStarted(notification))
|
||||
.await;
|
||||
}
|
||||
|
||||
let params = FileChangeRequestApprovalParams {
|
||||
thread_id: conversation_id.to_string(),
|
||||
@@ -559,14 +533,10 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.await;
|
||||
tokio::spawn(async move {
|
||||
on_file_change_request_approval_response(
|
||||
event_turn_id,
|
||||
conversation_id,
|
||||
item_id,
|
||||
patch_changes,
|
||||
pending_request_id,
|
||||
rx,
|
||||
conversation,
|
||||
outgoing,
|
||||
thread_state.clone(),
|
||||
permission_guard,
|
||||
)
|
||||
@@ -984,28 +954,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
EventMsg::ViewImageToolCall(view_image_event) => {
|
||||
let item = ThreadItem::ImageView {
|
||||
id: view_image_event.call_id.clone(),
|
||||
path: view_image_event.path.clone(),
|
||||
};
|
||||
let started = ItemStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item: item.clone(),
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemStarted(started))
|
||||
.await;
|
||||
let completed = ItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(completed))
|
||||
.await;
|
||||
}
|
||||
EventMsg::ViewImageToolCall(_) => {}
|
||||
EventMsg::EnteredReviewMode(review_request) => {
|
||||
let review = review_request
|
||||
.user_facing_hint
|
||||
@@ -1104,40 +1053,9 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
EventMsg::PatchApplyBegin(patch_begin_event) => {
|
||||
// Until we migrate the core to be aware of a first class FileChangeItem
|
||||
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
|
||||
let item_id = patch_begin_event.call_id.clone();
|
||||
|
||||
let first_start = {
|
||||
let mut state = thread_state.lock().await;
|
||||
state
|
||||
.turn_summary
|
||||
.file_change_started
|
||||
.insert(item_id.clone())
|
||||
};
|
||||
if first_start {
|
||||
let notification = item_event_to_server_notification(
|
||||
EventMsg::PatchApplyBegin(patch_begin_event),
|
||||
&conversation_id.to_string(),
|
||||
&event_turn_id,
|
||||
);
|
||||
outgoing.send_server_notification(notification).await;
|
||||
}
|
||||
}
|
||||
EventMsg::PatchApplyEnd(patch_end_event) => {
|
||||
// Until we migrate the core to be aware of a first class FileChangeItem
|
||||
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
|
||||
let item_id = patch_end_event.call_id.clone();
|
||||
complete_file_change_item(
|
||||
conversation_id,
|
||||
item_id,
|
||||
build_file_change_end_item(&patch_end_event),
|
||||
event_turn_id.clone(),
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await;
|
||||
EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) => {
|
||||
// Core still fans out these deprecated events for legacy clients;
|
||||
// v2 clients receive the canonical FileChange item instead.
|
||||
}
|
||||
EventMsg::ExecCommandBegin(exec_command_begin_event) => {
|
||||
if matches!(
|
||||
@@ -1425,31 +1343,6 @@ async fn emit_turn_completed_with_status(
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn complete_file_change_item(
|
||||
conversation_id: ThreadId,
|
||||
item_id: String,
|
||||
item: ThreadItem,
|
||||
turn_id: String,
|
||||
outgoing: &ThreadScopedOutgoingMessageSender,
|
||||
thread_state: &Arc<Mutex<ThreadState>>,
|
||||
) {
|
||||
thread_state
|
||||
.lock()
|
||||
.await
|
||||
.turn_summary
|
||||
.file_change_started
|
||||
.remove(&item_id);
|
||||
|
||||
let notification = ItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id,
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn start_command_execution_item(
|
||||
conversation_id: &ThreadId,
|
||||
@@ -2002,38 +1895,28 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_file_change_approval_decision(
|
||||
decision: FileChangeApprovalDecision,
|
||||
) -> (ReviewDecision, Option<PatchApplyStatus>) {
|
||||
fn map_file_change_approval_decision(decision: FileChangeApprovalDecision) -> ReviewDecision {
|
||||
match decision {
|
||||
FileChangeApprovalDecision::Accept => (ReviewDecision::Approved, None),
|
||||
FileChangeApprovalDecision::AcceptForSession => (ReviewDecision::ApprovedForSession, None),
|
||||
FileChangeApprovalDecision::Decline => {
|
||||
(ReviewDecision::Denied, Some(PatchApplyStatus::Declined))
|
||||
}
|
||||
FileChangeApprovalDecision::Cancel => {
|
||||
(ReviewDecision::Abort, Some(PatchApplyStatus::Declined))
|
||||
}
|
||||
FileChangeApprovalDecision::Accept => ReviewDecision::Approved,
|
||||
FileChangeApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession,
|
||||
FileChangeApprovalDecision::Decline => ReviewDecision::Denied,
|
||||
FileChangeApprovalDecision::Cancel => ReviewDecision::Abort,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn on_file_change_request_approval_response(
|
||||
event_turn_id: String,
|
||||
conversation_id: ThreadId,
|
||||
item_id: String,
|
||||
changes: Vec<FileUpdateChange>,
|
||||
pending_request_id: RequestId,
|
||||
receiver: oneshot::Receiver<ClientRequestResult>,
|
||||
codex: Arc<CodexThread>,
|
||||
outgoing: ThreadScopedOutgoingMessageSender,
|
||||
thread_state: Arc<Mutex<ThreadState>>,
|
||||
permission_guard: ThreadWatchActiveGuard,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
|
||||
drop(permission_guard);
|
||||
let (decision, completion_status) = match response {
|
||||
let decision = match response {
|
||||
Ok(Ok(value)) => {
|
||||
let response = serde_json::from_value::<FileChangeRequestApprovalResponse>(value)
|
||||
.unwrap_or_else(|err| {
|
||||
@@ -2043,39 +1926,19 @@ async fn on_file_change_request_approval_response(
|
||||
}
|
||||
});
|
||||
|
||||
let (decision, completion_status) =
|
||||
map_file_change_approval_decision(response.decision);
|
||||
// Allow EventMsg::PatchApplyEnd to emit ItemCompleted for accepted patches.
|
||||
// Only short-circuit on declines/cancels/failures.
|
||||
(decision, completion_status)
|
||||
map_file_change_approval_decision(response.decision)
|
||||
}
|
||||
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
|
||||
Ok(Err(err)) => {
|
||||
error!("request failed with client error: {err:?}");
|
||||
(ReviewDecision::Denied, Some(PatchApplyStatus::Failed))
|
||||
ReviewDecision::Denied
|
||||
}
|
||||
Err(err) => {
|
||||
error!("request failed: {err:?}");
|
||||
(ReviewDecision::Denied, Some(PatchApplyStatus::Failed))
|
||||
ReviewDecision::Denied
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(status) = completion_status {
|
||||
complete_file_change_item(
|
||||
conversation_id,
|
||||
item_id.clone(),
|
||||
ThreadItem::FileChange {
|
||||
id: item_id.clone(),
|
||||
changes,
|
||||
status,
|
||||
},
|
||||
event_turn_id.clone(),
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Err(err) = codex
|
||||
.submit(Op::PatchApproval {
|
||||
id: item_id,
|
||||
@@ -2687,12 +2550,7 @@ mod tests {
|
||||
thread_id: conversation_id,
|
||||
thread: conversation,
|
||||
..
|
||||
} = thread_manager
|
||||
.start_thread(
|
||||
config.clone(),
|
||||
codex_core::thread_store_from_config(&config),
|
||||
)
|
||||
.await?;
|
||||
} = thread_manager.start_thread(config.clone()).await?;
|
||||
let thread_state = new_thread_state();
|
||||
let thread_watch_manager = ThreadWatchManager::new();
|
||||
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
@@ -2891,10 +2749,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn file_change_accept_for_session_maps_to_approved_for_session() {
|
||||
let (decision, completion_status) =
|
||||
let decision =
|
||||
map_file_change_approval_decision(FileChangeApprovalDecision::AcceptForSession);
|
||||
assert_eq!(decision, ReviewDecision::ApprovedForSession);
|
||||
assert_eq!(completion_status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -122,10 +122,13 @@ use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginShareDeleteParams;
|
||||
use codex_app_server_protocol::PluginShareDeleteResponse;
|
||||
use codex_app_server_protocol::PluginShareListItem;
|
||||
use codex_app_server_protocol::PluginShareListParams;
|
||||
use codex_app_server_protocol::PluginShareListResponse;
|
||||
use codex_app_server_protocol::PluginShareSaveParams;
|
||||
use codex_app_server_protocol::PluginShareSaveResponse;
|
||||
use codex_app_server_protocol::PluginSkillReadParams;
|
||||
use codex_app_server_protocol::PluginSkillReadResponse;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::PluginUninstallParams;
|
||||
@@ -222,6 +225,7 @@ use codex_app_server_protocol::ThreadUnsubscribeParams;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeResponse;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeStatus;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnEnvironmentParams;
|
||||
use codex_app_server_protocol::TurnError;
|
||||
use codex_app_server_protocol::TurnInterruptParams;
|
||||
use codex_app_server_protocol::TurnInterruptResponse;
|
||||
@@ -259,7 +263,6 @@ use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::NetworkProxyAuditMetadata;
|
||||
use codex_core::config::ThreadStoreConfig;
|
||||
use codex_core::config::edit::ConfigEdit;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::exec::ExecCapturePolicy;
|
||||
@@ -271,7 +274,6 @@ use codex_core::find_thread_name_by_id;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use codex_core::path_utils;
|
||||
use codex_core::read_head_for_summary;
|
||||
use codex_core::read_session_meta_line;
|
||||
use codex_core::sandboxing::SandboxPermissions;
|
||||
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
|
||||
use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode;
|
||||
@@ -298,6 +300,7 @@ use codex_core_plugins::remote::RemoteMarketplace;
|
||||
use codex_core_plugins::remote::RemotePluginCatalogError;
|
||||
use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail;
|
||||
use codex_core_plugins::remote::RemotePluginServiceConfig;
|
||||
use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary;
|
||||
use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
@@ -378,12 +381,10 @@ use codex_state::ThreadMetadata;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
use codex_state::log_db::LogDbLayer;
|
||||
use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams;
|
||||
use codex_thread_store::InMemoryThreadStore;
|
||||
use codex_thread_store::ListThreadsParams as StoreListThreadsParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams;
|
||||
use codex_thread_store::ReadThreadParams as StoreReadThreadParams;
|
||||
use codex_thread_store::RemoteThreadStore;
|
||||
use codex_thread_store::SortDirection as StoreSortDirection;
|
||||
use codex_thread_store::StoredThread;
|
||||
use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch;
|
||||
@@ -691,18 +692,11 @@ pub(crate) struct CodexMessageProcessorArgs {
|
||||
/// go through `config_manager`.
|
||||
pub(crate) config: Arc<Config>,
|
||||
pub(crate) config_manager: ConfigManager,
|
||||
pub(crate) thread_store: Arc<dyn ThreadStore>,
|
||||
pub(crate) feedback: CodexFeedback,
|
||||
pub(crate) log_db: Option<LogDbLayer>,
|
||||
}
|
||||
|
||||
fn thread_store_from_config(config: &Config) -> Arc<dyn ThreadStore> {
|
||||
match &config.experimental_thread_store {
|
||||
ThreadStoreConfig::Local => Arc::new(configured_local_thread_store(config)),
|
||||
ThreadStoreConfig::Remote { endpoint } => Arc::new(RemoteThreadStore::new(endpoint)),
|
||||
ThreadStoreConfig::InMemory { id } => InMemoryThreadStore::for_id(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn environment_selection_error_message(err: CodexErr) -> String {
|
||||
match err {
|
||||
CodexErr::InvalidRequest(message) => message,
|
||||
@@ -710,10 +704,6 @@ fn environment_selection_error_message(err: CodexErr) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_local_thread_store(config: &Config) -> LocalThreadStore {
|
||||
LocalThreadStore::new(codex_rollout::RolloutConfig::from_view(config))
|
||||
}
|
||||
|
||||
impl CodexMessageProcessor {
|
||||
async fn instruction_sources_from_config(config: &Config) -> Vec<AbsolutePathBuf> {
|
||||
codex_core::AgentsMdManager::new(config)
|
||||
@@ -776,6 +766,39 @@ impl CodexMessageProcessor {
|
||||
self.thread_manager.skills_manager().clear_cache();
|
||||
}
|
||||
|
||||
async fn maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
config_manager: &ConfigManager,
|
||||
thread_manager: &Arc<ThreadManager>,
|
||||
auth: Option<CodexAuth>,
|
||||
) {
|
||||
match config_manager
|
||||
.load_latest_config(/*fallback_cwd*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
let refresh_thread_manager = Arc::clone(thread_manager);
|
||||
let refresh_config = config.clone();
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.maybe_start_remote_installed_plugins_cache_refresh(
|
||||
&config.plugins_config_input(),
|
||||
auth,
|
||||
Some(Arc::new(move || {
|
||||
Self::spawn_effective_plugins_changed_task(
|
||||
Arc::clone(&refresh_thread_manager),
|
||||
refresh_config.clone(),
|
||||
);
|
||||
})),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to reload config after account changed, skipping remote installed plugins cache refresh: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_account_updated_notification(&self) -> AccountUpdatedNotification {
|
||||
let auth = self.auth_manager.auth_cached();
|
||||
AccountUpdatedNotification {
|
||||
@@ -830,6 +853,7 @@ impl CodexMessageProcessor {
|
||||
arg0_paths,
|
||||
config,
|
||||
config_manager,
|
||||
thread_store,
|
||||
feedback,
|
||||
log_db,
|
||||
} = args;
|
||||
@@ -839,7 +863,7 @@ impl CodexMessageProcessor {
|
||||
outgoing: outgoing.clone(),
|
||||
analytics_events_client,
|
||||
arg0_paths,
|
||||
thread_store: thread_store_from_config(&config),
|
||||
thread_store,
|
||||
config,
|
||||
config_manager,
|
||||
active_login: Arc::new(Mutex::new(None)),
|
||||
@@ -1127,6 +1151,10 @@ impl CodexMessageProcessor {
|
||||
self.plugin_read(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::PluginSkillRead { request_id, params } => {
|
||||
self.plugin_skill_read(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::PluginShareSave { request_id, params } => {
|
||||
self.plugin_share_save(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
@@ -1566,10 +1594,10 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
let outgoing_clone = self.outgoing.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let config_manager = self.config_manager.clone();
|
||||
let thread_manager = Arc::clone(&self.thread_manager);
|
||||
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
let auth_url = server.auth_url.clone();
|
||||
tokio::spawn(async move {
|
||||
let (success, error_msg) = match tokio::time::timeout(
|
||||
@@ -1588,8 +1616,8 @@ impl CodexMessageProcessor {
|
||||
|
||||
Self::send_chatgpt_login_completion_notifications(
|
||||
&outgoing_clone,
|
||||
auth_manager,
|
||||
config_manager,
|
||||
thread_manager,
|
||||
chatgpt_base_url,
|
||||
login_id,
|
||||
success,
|
||||
@@ -1642,10 +1670,10 @@ impl CodexMessageProcessor {
|
||||
let user_code = device_code.user_code.clone();
|
||||
|
||||
let outgoing_clone = self.outgoing.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let config_manager = self.config_manager.clone();
|
||||
let thread_manager = Arc::clone(&self.thread_manager);
|
||||
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
tokio::spawn(async move {
|
||||
let (success, error_msg) = tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
@@ -1661,8 +1689,8 @@ impl CodexMessageProcessor {
|
||||
|
||||
Self::send_chatgpt_login_completion_notifications(
|
||||
&outgoing_clone,
|
||||
auth_manager,
|
||||
config_manager,
|
||||
thread_manager,
|
||||
chatgpt_base_url,
|
||||
login_id,
|
||||
success,
|
||||
@@ -1791,6 +1819,13 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
async fn send_login_success_notifications(&self, login_id: Option<Uuid>) {
|
||||
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
&self.config_manager,
|
||||
&self.thread_manager,
|
||||
self.auth_manager.auth_cached(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let payload_login_completed = AccountLoginCompletedNotification {
|
||||
login_id: login_id.map(|id| id.to_string()),
|
||||
success: true,
|
||||
@@ -1811,8 +1846,8 @@ impl CodexMessageProcessor {
|
||||
|
||||
async fn send_chatgpt_login_completion_notifications(
|
||||
outgoing: &OutgoingMessageSender,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
config_manager: ConfigManager,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
chatgpt_base_url: String,
|
||||
login_id: Uuid,
|
||||
success: bool,
|
||||
@@ -1828,6 +1863,7 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
|
||||
if success {
|
||||
let auth_manager = thread_manager.auth_manager();
|
||||
auth_manager.reload().await;
|
||||
config_manager
|
||||
.replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url);
|
||||
@@ -1836,6 +1872,12 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
|
||||
let auth = auth_manager.auth_cached();
|
||||
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
&config_manager,
|
||||
&thread_manager,
|
||||
auth.clone(),
|
||||
)
|
||||
.await;
|
||||
let payload_v2 = AccountUpdatedNotification {
|
||||
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
|
||||
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
|
||||
@@ -1866,6 +1908,13 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
&self.config_manager,
|
||||
&self.thread_manager,
|
||||
self.auth_manager.auth_cached(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Reflect the current auth method after logout (likely None).
|
||||
Ok(self
|
||||
.auth_manager
|
||||
@@ -2479,28 +2528,13 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let environments = environments.map(|environments| {
|
||||
environments
|
||||
.into_iter()
|
||||
.map(|environment| TurnEnvironmentSelection {
|
||||
environment_id: environment.environment_id,
|
||||
cwd: environment.cwd,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
if let Some(environments) = environments.as_ref()
|
||||
&& let Err(err) = self
|
||||
.thread_manager
|
||||
.validate_environment_selections(environments)
|
||||
{
|
||||
self.outgoing
|
||||
.send_error(
|
||||
request_id,
|
||||
invalid_request(environment_selection_error_message(err)),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let environment_selections = match self.parse_environment_selections(environments) {
|
||||
Ok(environment_selections) => environment_selections,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut typesafe_overrides = self.build_thread_config_overrides(
|
||||
model,
|
||||
model_provider,
|
||||
@@ -2539,7 +2573,7 @@ impl CodexMessageProcessor {
|
||||
typesafe_overrides,
|
||||
dynamic_tools,
|
||||
session_start_source,
|
||||
environments,
|
||||
environment_selections,
|
||||
persist_extended_history,
|
||||
service_name,
|
||||
experimental_raw_events,
|
||||
@@ -2586,7 +2620,6 @@ impl CodexMessageProcessor {
|
||||
let imported_thread = self
|
||||
.thread_manager
|
||||
.start_thread_with_options(StartThreadOptions {
|
||||
thread_store: thread_store_from_config(&config),
|
||||
config,
|
||||
initial_history: InitialHistory::Forked(rollout_items),
|
||||
session_source: None,
|
||||
@@ -2784,7 +2817,6 @@ impl CodexMessageProcessor {
|
||||
} = listener_task_context
|
||||
.thread_manager
|
||||
.start_thread_with_options(StartThreadOptions {
|
||||
thread_store: thread_store_from_config(&config),
|
||||
config,
|
||||
initial_history: match session_start_source
|
||||
.unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup)
|
||||
@@ -2966,6 +2998,27 @@ impl CodexMessageProcessor {
|
||||
overrides
|
||||
}
|
||||
|
||||
fn parse_environment_selections(
|
||||
&self,
|
||||
environments: Option<Vec<TurnEnvironmentParams>>,
|
||||
) -> Result<Option<Vec<TurnEnvironmentSelection>>, JSONRPCErrorError> {
|
||||
let environment_selections = environments.map(|environments| {
|
||||
environments
|
||||
.into_iter()
|
||||
.map(|environment| TurnEnvironmentSelection {
|
||||
environment_id: environment.environment_id,
|
||||
cwd: environment.cwd,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
if let Some(environment_selections) = environment_selections.as_ref() {
|
||||
self.thread_manager
|
||||
.validate_environment_selections(environment_selections)
|
||||
.map_err(|err| invalid_request(environment_selection_error_message(err)))?;
|
||||
}
|
||||
Ok(environment_selections)
|
||||
}
|
||||
|
||||
async fn thread_archive(&self, request_id: ConnectionRequestId, params: ThreadArchiveParams) {
|
||||
let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await {
|
||||
Ok(permit) => permit,
|
||||
@@ -3900,16 +3953,48 @@ impl CodexMessageProcessor {
|
||||
include_turns: bool,
|
||||
) -> Result<Thread, ThreadReadViewError> {
|
||||
let loaded_thread = self.thread_manager.get_thread(thread_id).await.ok();
|
||||
let mut thread = if let Some(thread) = self
|
||||
let mut thread = if include_turns {
|
||||
if let Some(loaded_thread) = loaded_thread.as_ref() {
|
||||
// Loaded thread with turns: use persisted metadata when it exists,
|
||||
// but reconstruct turns from the live ThreadStore history.
|
||||
let persisted_thread = self
|
||||
.load_persisted_thread_for_read(thread_id, /*include_turns*/ false)
|
||||
.await?;
|
||||
self.load_live_thread_view(
|
||||
thread_id,
|
||||
include_turns,
|
||||
loaded_thread,
|
||||
persisted_thread,
|
||||
)
|
||||
.await?
|
||||
} else if let Some(thread) = self
|
||||
.load_persisted_thread_for_read(thread_id, include_turns)
|
||||
.await?
|
||||
{
|
||||
// Unloaded thread with turns: load metadata and history together
|
||||
// from the ThreadStore.
|
||||
thread
|
||||
} else {
|
||||
return Err(ThreadReadViewError::InvalidRequest(format!(
|
||||
"thread not loaded: {thread_id}"
|
||||
)));
|
||||
}
|
||||
} else if let Some(thread) = self
|
||||
.load_persisted_thread_for_read(thread_id, include_turns)
|
||||
.await?
|
||||
{
|
||||
// Persisted metadata-only read: no live thread state is needed.
|
||||
thread
|
||||
} else if let Some(thread) = self
|
||||
.load_live_thread_view(thread_id, include_turns, loaded_thread.as_ref())
|
||||
} else if let Some(loaded_thread) = loaded_thread.as_ref() {
|
||||
// Loaded metadata-only read before persistence is materialized: build
|
||||
// the response from the live thread snapshot.
|
||||
self.load_live_thread_view(
|
||||
thread_id,
|
||||
include_turns,
|
||||
loaded_thread,
|
||||
/*persisted_thread*/ None,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
thread
|
||||
} else {
|
||||
return Err(ThreadReadViewError::InvalidRequest(format!(
|
||||
"thread not loaded: {thread_id}"
|
||||
@@ -3975,65 +4060,51 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `thread/read` view from a loaded thread plus optional persisted metadata.
|
||||
async fn load_live_thread_view(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
include_turns: bool,
|
||||
loaded_thread: Option<&Arc<CodexThread>>,
|
||||
) -> Result<Option<Thread>, ThreadReadViewError> {
|
||||
let Some(thread) = loaded_thread else {
|
||||
return Ok(None);
|
||||
};
|
||||
let config_snapshot = thread.config_snapshot().await;
|
||||
let loaded_rollout_path = thread.rollout_path();
|
||||
if include_turns && loaded_rollout_path.is_none() {
|
||||
loaded_thread: &CodexThread,
|
||||
persisted_thread: Option<Thread>,
|
||||
) -> Result<Thread, ThreadReadViewError> {
|
||||
let config_snapshot = loaded_thread.config_snapshot().await;
|
||||
if include_turns && config_snapshot.ephemeral {
|
||||
return Err(ThreadReadViewError::InvalidRequest(
|
||||
"ephemeral threads do not support includeTurns".to_string(),
|
||||
));
|
||||
}
|
||||
let mut thread =
|
||||
build_thread_from_snapshot(thread_id, &config_snapshot, loaded_rollout_path.clone());
|
||||
self.apply_thread_read_rollout_fields(
|
||||
thread_id,
|
||||
&mut thread,
|
||||
loaded_rollout_path.as_deref(),
|
||||
include_turns,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(thread))
|
||||
let fallback_thread =
|
||||
build_thread_from_loaded_snapshot(thread_id, &config_snapshot, loaded_thread);
|
||||
let mut thread = if let Some(mut thread) = persisted_thread {
|
||||
if thread.path.is_none() {
|
||||
thread.path = fallback_thread.path.clone();
|
||||
}
|
||||
thread.ephemeral = fallback_thread.ephemeral;
|
||||
thread
|
||||
} else {
|
||||
fallback_thread
|
||||
};
|
||||
self.apply_thread_read_store_fields(thread_id, &mut thread, include_turns, loaded_thread)
|
||||
.await?;
|
||||
Ok(thread)
|
||||
}
|
||||
|
||||
async fn apply_thread_read_rollout_fields(
|
||||
async fn apply_thread_read_store_fields(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
thread: &mut Thread,
|
||||
rollout_path: Option<&Path>,
|
||||
include_turns: bool,
|
||||
loaded_thread: &CodexThread,
|
||||
) -> Result<(), ThreadReadViewError> {
|
||||
if thread.forked_from_id.is_none()
|
||||
&& let Some(rollout_path) = rollout_path
|
||||
{
|
||||
thread.forked_from_id = forked_from_id_from_rollout(rollout_path).await;
|
||||
}
|
||||
self.attach_thread_name(thread_id, thread).await;
|
||||
|
||||
if include_turns && let Some(rollout_path) = rollout_path {
|
||||
match read_rollout_items_from_rollout(rollout_path).await {
|
||||
Ok(items) => {
|
||||
thread.turns = build_turns_from_rollout_items(&items);
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(ThreadReadViewError::InvalidRequest(format!(
|
||||
"thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message"
|
||||
)));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(ThreadReadViewError::Internal(format!(
|
||||
"failed to load rollout `{}` for thread {thread_id}: {err}",
|
||||
rollout_path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
if include_turns {
|
||||
let history = loaded_thread
|
||||
.load_history(/*include_archived*/ true)
|
||||
.await
|
||||
.map_err(|err| thread_read_history_load_error(thread_id, err))?;
|
||||
thread.turns = build_turns_from_rollout_items(&history.items);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -4348,7 +4419,6 @@ impl CodexMessageProcessor {
|
||||
.thread_manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
thread_history,
|
||||
self.auth_manager.clone(),
|
||||
persist_extended_history,
|
||||
@@ -4503,27 +4573,20 @@ impl CodexMessageProcessor {
|
||||
request_id: &ConnectionRequestId,
|
||||
params: &ThreadResumeParams,
|
||||
) -> Result<bool, JSONRPCErrorError> {
|
||||
if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id)
|
||||
&& let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await
|
||||
{
|
||||
if params.history.is_some() {
|
||||
let running_thread = if params.history.is_some() {
|
||||
if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id)
|
||||
&& self
|
||||
.thread_manager
|
||||
.get_thread(existing_thread_id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(invalid_request(format!(
|
||||
"cannot resume thread {existing_thread_id} with history while it is already running"
|
||||
)));
|
||||
}
|
||||
|
||||
if let (Some(requested_path), Some(active_path)) = (
|
||||
params.path.as_ref(),
|
||||
existing_thread.rollout_path().as_ref(),
|
||||
) && requested_path != active_path
|
||||
{
|
||||
return Err(invalid_request(format!(
|
||||
"cannot resume running thread {existing_thread_id} with mismatched path: requested `{}`, active `{}`",
|
||||
requested_path.display(),
|
||||
active_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
None
|
||||
} else if params.path.is_some() {
|
||||
let source_thread = self
|
||||
.read_stored_thread_for_resume(
|
||||
¶ms.thread_id,
|
||||
@@ -4531,12 +4594,45 @@ impl CodexMessageProcessor {
|
||||
/*include_history*/ true,
|
||||
)
|
||||
.await?;
|
||||
let existing_thread_id = source_thread.thread_id;
|
||||
if let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await {
|
||||
if let (Some(requested_path), Some(active_path)) = (
|
||||
params.path.as_ref(),
|
||||
existing_thread.rollout_path().as_ref(),
|
||||
) && requested_path != active_path
|
||||
{
|
||||
return Err(invalid_request(format!(
|
||||
"cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`",
|
||||
requested_path.display(),
|
||||
active_path.display()
|
||||
)));
|
||||
}
|
||||
Some((existing_thread_id, existing_thread, source_thread))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id)
|
||||
&& let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await
|
||||
{
|
||||
let source_thread = self
|
||||
.read_stored_thread_for_resume(
|
||||
¶ms.thread_id,
|
||||
/*path*/ None,
|
||||
/*include_history*/ true,
|
||||
)
|
||||
.await?;
|
||||
if source_thread.thread_id != existing_thread_id {
|
||||
return Err(invalid_request(format!(
|
||||
"cannot resume running thread {existing_thread_id} from source thread {}",
|
||||
source_thread.thread_id
|
||||
)));
|
||||
}
|
||||
Some((existing_thread_id, existing_thread, source_thread))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread {
|
||||
let history_items = source_thread
|
||||
.history
|
||||
.as_ref()
|
||||
@@ -4731,11 +4827,10 @@ impl CodexMessageProcessor {
|
||||
|
||||
async fn read_stored_thread_for_new_fork(
|
||||
&self,
|
||||
thread_store: &dyn ThreadStore,
|
||||
thread_id: ThreadId,
|
||||
include_history: bool,
|
||||
) -> Result<StoredThread, JSONRPCErrorError> {
|
||||
thread_store
|
||||
self.thread_store
|
||||
.read_thread(StoreReadThreadParams {
|
||||
thread_id,
|
||||
include_archived: true,
|
||||
@@ -4938,7 +5033,6 @@ impl CodexMessageProcessor {
|
||||
|
||||
let fallback_model_provider = config.model_provider_id.clone();
|
||||
let instruction_sources = Self::instruction_sources_from_config(&config).await;
|
||||
let fork_thread_store = thread_store_from_config(&config);
|
||||
|
||||
let NewThread {
|
||||
thread_id,
|
||||
@@ -4950,7 +5044,6 @@ impl CodexMessageProcessor {
|
||||
.fork_thread_from_history(
|
||||
ForkSnapshot::Interrupted,
|
||||
config,
|
||||
fork_thread_store.clone(),
|
||||
InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id: source_thread_id,
|
||||
history: history_items.clone(),
|
||||
@@ -4986,11 +5079,7 @@ impl CodexMessageProcessor {
|
||||
let mut thread =
|
||||
if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref() {
|
||||
let stored_thread = self
|
||||
.read_stored_thread_for_new_fork(
|
||||
fork_thread_store.as_ref(),
|
||||
thread_id,
|
||||
include_turns,
|
||||
)
|
||||
.read_stored_thread_for_new_fork(thread_id, include_turns)
|
||||
.await?;
|
||||
self.stored_thread_to_api_thread(
|
||||
stored_thread,
|
||||
@@ -6604,21 +6693,7 @@ impl CodexMessageProcessor {
|
||||
let collaboration_mode = params
|
||||
.collaboration_mode
|
||||
.map(|mode| self.normalize_turn_start_collaboration_mode(mode));
|
||||
let environments: Option<Vec<TurnEnvironmentSelection>> =
|
||||
params.environments.map(|environments| {
|
||||
environments
|
||||
.into_iter()
|
||||
.map(|environment| TurnEnvironmentSelection {
|
||||
environment_id: environment.environment_id,
|
||||
cwd: environment.cwd,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
if let Some(environments) = environments.as_ref() {
|
||||
self.thread_manager
|
||||
.validate_environment_selections(environments)
|
||||
.map_err(|err| invalid_request(environment_selection_error_message(err)))?;
|
||||
}
|
||||
let environment_selections = self.parse_environment_selections(params.environments)?;
|
||||
|
||||
// Map v2 input items to core input items.
|
||||
let mapped_items: Vec<CoreInputItem> = params
|
||||
@@ -6727,7 +6802,7 @@ impl CodexMessageProcessor {
|
||||
let turn_op = if has_any_overrides {
|
||||
Op::UserInputWithTurnContext {
|
||||
items: mapped_items,
|
||||
environments,
|
||||
environments: environment_selections,
|
||||
final_output_json_schema: params.output_schema,
|
||||
responsesapi_client_metadata: params.responsesapi_client_metadata,
|
||||
cwd,
|
||||
@@ -6747,7 +6822,7 @@ impl CodexMessageProcessor {
|
||||
} else {
|
||||
Op::UserInput {
|
||||
items: mapped_items,
|
||||
environments,
|
||||
environments: environment_selections,
|
||||
final_output_json_schema: params.output_schema,
|
||||
responsesapi_client_metadata: params.responsesapi_client_metadata,
|
||||
}
|
||||
@@ -7250,7 +7325,6 @@ impl CodexMessageProcessor {
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
rollout_path,
|
||||
/*persist_extended_history*/ false,
|
||||
self.request_trace_context(request_id).await,
|
||||
@@ -9081,6 +9155,32 @@ fn thread_turns_list_history_load_error(
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_read_history_load_error(
|
||||
thread_id: ThreadId,
|
||||
err: ThreadStoreError,
|
||||
) -> ThreadReadViewError {
|
||||
match err {
|
||||
ThreadStoreError::InvalidRequest { message }
|
||||
if message.starts_with("failed to resolve rollout path `") =>
|
||||
{
|
||||
ThreadReadViewError::InvalidRequest(format!(
|
||||
"thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message"
|
||||
))
|
||||
}
|
||||
ThreadStoreError::ThreadNotFound {
|
||||
thread_id: missing_thread_id,
|
||||
} if missing_thread_id == thread_id => ThreadReadViewError::InvalidRequest(format!(
|
||||
"thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message"
|
||||
)),
|
||||
ThreadStoreError::InvalidRequest { message } => {
|
||||
ThreadReadViewError::InvalidRequest(message)
|
||||
}
|
||||
err => ThreadReadViewError::Internal(format!(
|
||||
"failed to load thread history for thread {thread_id}: {err}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn conversation_summary_thread_id_read_error(
|
||||
conversation_id: ThreadId,
|
||||
err: ThreadStoreError,
|
||||
@@ -9500,8 +9600,9 @@ fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn forked_from_id_from_rollout(path: &Path) -> Option<String> {
|
||||
read_session_meta_line(path)
|
||||
codex_core::read_session_meta_line(path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|meta_line| meta_line.meta.forked_from_id)
|
||||
@@ -9679,6 +9780,14 @@ fn build_thread_from_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_thread_from_loaded_snapshot(
|
||||
thread_id: ThreadId,
|
||||
config_snapshot: &ThreadConfigSnapshot,
|
||||
loaded_thread: &CodexThread,
|
||||
) -> Thread {
|
||||
build_thread_from_snapshot(thread_id, config_snapshot, loaded_thread.rollout_path())
|
||||
}
|
||||
|
||||
fn thread_started_notification(mut thread: Thread) -> ThreadStartedNotification {
|
||||
thread.turns.clear();
|
||||
ThreadStartedNotification { thread }
|
||||
|
||||
@@ -3,6 +3,8 @@ use crate::error_code::internal_error;
|
||||
use crate::error_code::invalid_request;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_core_plugins::remote::is_valid_remote_plugin_id;
|
||||
use codex_core_plugins::remote::validate_remote_plugin_id;
|
||||
|
||||
impl CodexMessageProcessor {
|
||||
pub(super) async fn plugin_list(
|
||||
@@ -261,15 +263,15 @@ impl CodexMessageProcessor {
|
||||
if !config.features.enabled(Feature::Plugins)
|
||||
|| !config.features.enabled(Feature::RemotePlugin)
|
||||
{
|
||||
return Err(invalid_request("remote plugin read is not enabled"));
|
||||
return Err(invalid_request(format!(
|
||||
"remote plugin read is not enabled for marketplace {remote_marketplace_name}"
|
||||
)));
|
||||
}
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) {
|
||||
return Err(invalid_request("invalid remote plugin id"));
|
||||
}
|
||||
validate_remote_plugin_id(&plugin_name)?;
|
||||
let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
@@ -300,6 +302,61 @@ impl CodexMessageProcessor {
|
||||
Ok(PluginReadResponse { plugin })
|
||||
}
|
||||
|
||||
pub(super) async fn plugin_skill_read(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: PluginSkillReadParams,
|
||||
) {
|
||||
let result = self.plugin_skill_read_response(params).await;
|
||||
self.outgoing.send_result(request_id, result).await;
|
||||
}
|
||||
|
||||
async fn plugin_skill_read_response(
|
||||
&self,
|
||||
params: PluginSkillReadParams,
|
||||
) -> Result<PluginSkillReadResponse, JSONRPCErrorError> {
|
||||
let PluginSkillReadParams {
|
||||
remote_marketplace_name,
|
||||
remote_plugin_id,
|
||||
skill_name,
|
||||
} = params;
|
||||
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
if !config.features.enabled(Feature::Plugins)
|
||||
|| !config.features.enabled(Feature::RemotePlugin)
|
||||
{
|
||||
return Err(invalid_request(format!(
|
||||
"remote plugin skill read is not enabled for marketplace {remote_marketplace_name}"
|
||||
)));
|
||||
}
|
||||
validate_remote_plugin_id(&remote_plugin_id)?;
|
||||
if skill_name.is_empty() {
|
||||
return Err(invalid_request(
|
||||
"invalid remote plugin skill name: cannot be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
&remote_marketplace_name,
|
||||
&remote_plugin_id,
|
||||
&skill_name,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin skill details")
|
||||
})?;
|
||||
|
||||
Ok(PluginSkillReadResponse {
|
||||
contents: remote_skill_detail.contents,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn plugin_share_save(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
@@ -330,14 +387,16 @@ impl CodexMessageProcessor {
|
||||
let result = codex_core_plugins::remote::save_remote_plugin_share(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
plugin_path.as_path(),
|
||||
config.codex_home.as_path(),
|
||||
&plugin_path,
|
||||
remote_plugin_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?;
|
||||
let remote_plugin_id = result.remote_plugin_id;
|
||||
self.clear_plugin_related_caches();
|
||||
Ok(PluginShareSaveResponse {
|
||||
remote_plugin_id: result.remote_plugin_id,
|
||||
remote_plugin_id,
|
||||
share_url: result.share_url.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
@@ -361,11 +420,24 @@ impl CodexMessageProcessor {
|
||||
let data = codex_core_plugins::remote::list_remote_plugin_shares(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
config.codex_home.as_path(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))?
|
||||
.into_iter()
|
||||
.map(remote_plugin_summary_to_info)
|
||||
.map(|summary| {
|
||||
let RemoteCatalogPluginShareSummary {
|
||||
summary,
|
||||
share_url,
|
||||
local_plugin_path,
|
||||
} = summary;
|
||||
let plugin = remote_plugin_summary_to_info(summary);
|
||||
PluginShareListItem {
|
||||
plugin,
|
||||
share_url: share_url.unwrap_or_default(),
|
||||
local_plugin_path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(PluginShareListResponse { data })
|
||||
}
|
||||
@@ -395,6 +467,7 @@ impl CodexMessageProcessor {
|
||||
codex_core_plugins::remote::delete_remote_plugin_share(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
config.codex_home.as_path(),
|
||||
&remote_plugin_id,
|
||||
)
|
||||
.await
|
||||
@@ -514,13 +587,11 @@ impl CodexMessageProcessor {
|
||||
if !config.features.enabled(Feature::Plugins)
|
||||
|| !config.features.enabled(Feature::RemotePlugin)
|
||||
{
|
||||
return Err(invalid_request("remote plugin install is not enabled"));
|
||||
}
|
||||
if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) {
|
||||
return Err(invalid_request(
|
||||
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed",
|
||||
));
|
||||
return Err(invalid_request(format!(
|
||||
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
|
||||
)));
|
||||
}
|
||||
validate_remote_plugin_id(&remote_plugin_id)?;
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
@@ -703,13 +774,13 @@ impl CodexMessageProcessor {
|
||||
) -> Result<PluginUninstallResponse, JSONRPCErrorError> {
|
||||
let PluginUninstallParams { plugin_id } = params;
|
||||
if codex_plugin::PluginId::parse(&plugin_id).is_err()
|
||||
&& (plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id))
|
||||
&& !is_valid_remote_uninstall_plugin_id(&plugin_id)
|
||||
{
|
||||
return Err(invalid_request(
|
||||
"invalid plugin id: expected a local plugin id or remote plugin id",
|
||||
"invalid plugin id: expected a local plugin id in the form `plugin@marketplace` or a remote plugin id starting with `plugins~`, `plugins_`, `app_`, `asdk_app_`, or `connector_`",
|
||||
));
|
||||
}
|
||||
if !plugin_id.is_empty() && is_valid_remote_plugin_id(&plugin_id) {
|
||||
if is_valid_remote_uninstall_plugin_id(&plugin_id) {
|
||||
return self.remote_plugin_uninstall_response(plugin_id).await;
|
||||
}
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
@@ -800,9 +871,7 @@ impl CodexMessageProcessor {
|
||||
{
|
||||
return Err(invalid_request("remote plugin uninstall is not enabled"));
|
||||
}
|
||||
if plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id) {
|
||||
return Err(invalid_request("invalid remote plugin id"));
|
||||
}
|
||||
validate_remote_plugin_id(&plugin_id)?;
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
@@ -838,10 +907,13 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_remote_plugin_id(plugin_name: &str) -> bool {
|
||||
plugin_name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
|
||||
fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool {
|
||||
is_valid_remote_plugin_id(plugin_name)
|
||||
&& (plugin_name.starts_with("plugins~")
|
||||
|| plugin_name.starts_with("plugins_")
|
||||
|| plugin_name.starts_with("app_")
|
||||
|| plugin_name.starts_with("asdk_app_")
|
||||
|| plugin_name.starts_with("connector_"))
|
||||
}
|
||||
|
||||
fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry {
|
||||
@@ -919,7 +991,8 @@ fn remote_plugin_catalog_error_to_jsonrpc(
|
||||
}
|
||||
}
|
||||
RemotePluginCatalogError::InvalidPluginPath { .. }
|
||||
| RemotePluginCatalogError::ArchiveTooLarge { .. } => JSONRPCErrorError {
|
||||
| RemotePluginCatalogError::ArchiveTooLarge { .. }
|
||||
| RemotePluginCatalogError::UnknownMarketplace { .. } => JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("{context}: {err}"),
|
||||
data: None,
|
||||
@@ -928,7 +1001,10 @@ fn remote_plugin_catalog_error_to_jsonrpc(
|
||||
| RemotePluginCatalogError::Request { .. }
|
||||
| RemotePluginCatalogError::UnexpectedStatus { .. }
|
||||
| RemotePluginCatalogError::Decode { .. }
|
||||
| RemotePluginCatalogError::InvalidBaseUrl(_)
|
||||
| RemotePluginCatalogError::InvalidBaseUrlPath
|
||||
| RemotePluginCatalogError::UnexpectedPluginId { .. }
|
||||
| RemotePluginCatalogError::UnexpectedSkillName { .. }
|
||||
| RemotePluginCatalogError::UnexpectedEnabledState { .. }
|
||||
| RemotePluginCatalogError::Archive { .. }
|
||||
| RemotePluginCatalogError::ArchiveJoin(_)
|
||||
|
||||
@@ -65,6 +65,7 @@ use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::thread_store_from_config;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_features::Feature;
|
||||
use codex_feedback::CodexFeedback;
|
||||
@@ -285,12 +286,17 @@ impl MessageProcessor {
|
||||
auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge {
|
||||
outgoing: outgoing.clone(),
|
||||
}));
|
||||
// The thread store is intentionally process-scoped. Config reloads can
|
||||
// affect per-thread behavior, but they must not move newly started,
|
||||
// resumed, or forked threads to a different persistence backend/root.
|
||||
let thread_store = thread_store_from_config(config.as_ref());
|
||||
let thread_manager = Arc::new(ThreadManager::new(
|
||||
config.as_ref(),
|
||||
auth_manager.clone(),
|
||||
session_source,
|
||||
environment_manager,
|
||||
Some(analytics_events_client.clone()),
|
||||
Arc::clone(&thread_store),
|
||||
));
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
@@ -304,6 +310,7 @@ impl MessageProcessor {
|
||||
arg0_paths,
|
||||
config: Arc::clone(&config),
|
||||
config_manager: config_manager.clone(),
|
||||
thread_store,
|
||||
feedback,
|
||||
log_db,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicI64;
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -15,7 +14,6 @@ use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_otel::span_w3c_trace_context;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
@@ -26,22 +24,17 @@ use tracing::warn;
|
||||
use crate::error_code::INTERNAL_ERROR_CODE;
|
||||
use crate::error_code::internal_error;
|
||||
use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON;
|
||||
pub(crate) use codex_app_server_transport::ConnectionId;
|
||||
pub(crate) use codex_app_server_transport::OutgoingError;
|
||||
pub(crate) use codex_app_server_transport::OutgoingMessage;
|
||||
pub(crate) use codex_app_server_transport::OutgoingResponse;
|
||||
pub(crate) use codex_app_server_transport::QueuedOutgoingMessage;
|
||||
|
||||
#[cfg(test)]
|
||||
use codex_protocol::account::PlanType;
|
||||
|
||||
pub(crate) type ClientRequestResult = std::result::Result<Result, JSONRPCErrorError>;
|
||||
|
||||
/// Stable identifier for a transport connection.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub(crate) struct ConnectionId(pub(crate) u64);
|
||||
|
||||
impl fmt::Display for ConnectionId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable identifier for a client request scoped to a transport connection.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub(crate) struct ConnectionRequestId {
|
||||
@@ -96,21 +89,6 @@ pub(crate) enum OutgoingEnvelope {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct QueuedOutgoingMessage {
|
||||
pub(crate) message: OutgoingMessage,
|
||||
pub(crate) write_complete_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl QueuedOutgoingMessage {
|
||||
pub(crate) fn new(message: OutgoingMessage) -> Self {
|
||||
Self {
|
||||
message,
|
||||
write_complete_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends messages to the client and manages request callbacks.
|
||||
pub(crate) struct OutgoingMessageSender {
|
||||
next_server_request_id: AtomicI64,
|
||||
@@ -665,30 +643,6 @@ impl OutgoingMessageSender {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outgoing message from the server to the client.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum OutgoingMessage {
|
||||
Request(ServerRequest),
|
||||
/// AppServerNotification is specific to the case where this is run as an
|
||||
/// "app server" as opposed to an MCP server.
|
||||
AppServerNotification(ServerNotification),
|
||||
Response(OutgoingResponse),
|
||||
Error(OutgoingError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub(crate) struct OutgoingResponse {
|
||||
pub id: RequestId,
|
||||
pub result: Result,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub(crate) struct OutgoingError {
|
||||
pub error: JSONRPCErrorError,
|
||||
pub id: RequestId,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -61,7 +61,6 @@ pub(crate) enum ThreadListenerCommand {
|
||||
#[derive(Default, Clone)]
|
||||
pub(crate) struct TurnSummary {
|
||||
pub(crate) started_at: Option<i64>,
|
||||
pub(crate) file_change_started: HashSet<String>,
|
||||
pub(crate) command_execution_started: HashSet<String>,
|
||||
pub(crate) last_error: Option<TurnError>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
use crate::message_processor::ConnectionSessionState;
|
||||
use crate::outgoing_message::OutgoingEnvelope;
|
||||
use codex_app_server_protocol::ExperimentalApi;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
pub use codex_app_server_transport::AppServerTransport;
|
||||
pub(crate) use codex_app_server_transport::CHANNEL_CAPACITY;
|
||||
pub(crate) use codex_app_server_transport::ConnectionId;
|
||||
pub(crate) use codex_app_server_transport::ConnectionOrigin;
|
||||
pub(crate) use codex_app_server_transport::OutgoingMessage;
|
||||
pub(crate) use codex_app_server_transport::QueuedOutgoingMessage;
|
||||
pub(crate) use codex_app_server_transport::RemoteControlHandle;
|
||||
pub(crate) use codex_app_server_transport::TransportEvent;
|
||||
pub use codex_app_server_transport::app_server_control_socket_path;
|
||||
pub use codex_app_server_transport::auth;
|
||||
pub(crate) use codex_app_server_transport::start_control_socket_acceptor;
|
||||
pub(crate) use codex_app_server_transport::start_remote_control;
|
||||
pub(crate) use codex_app_server_transport::start_stdio_connection;
|
||||
pub(crate) use codex_app_server_transport::start_websocket_acceptor;
|
||||
|
||||
pub(crate) struct ConnectionState {
|
||||
pub(crate) outbound_initialized: Arc<AtomicBool>,
|
||||
pub(crate) outbound_experimental_api_enabled: Arc<AtomicBool>,
|
||||
pub(crate) outbound_opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
|
||||
pub(crate) session: Arc<ConnectionSessionState>,
|
||||
}
|
||||
|
||||
impl ConnectionState {
|
||||
pub(crate) fn new(
|
||||
origin: ConnectionOrigin,
|
||||
outbound_initialized: Arc<AtomicBool>,
|
||||
outbound_experimental_api_enabled: Arc<AtomicBool>,
|
||||
outbound_opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
outbound_initialized,
|
||||
outbound_experimental_api_enabled,
|
||||
outbound_opted_out_notification_methods,
|
||||
session: Arc::new(ConnectionSessionState::new(origin)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct OutboundConnectionState {
|
||||
pub(crate) initialized: Arc<AtomicBool>,
|
||||
pub(crate) experimental_api_enabled: Arc<AtomicBool>,
|
||||
pub(crate) opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
|
||||
pub(crate) writer: mpsc::Sender<QueuedOutgoingMessage>,
|
||||
disconnect_sender: Option<CancellationToken>,
|
||||
}
|
||||
|
||||
impl OutboundConnectionState {
|
||||
pub(crate) fn new(
|
||||
writer: mpsc::Sender<QueuedOutgoingMessage>,
|
||||
initialized: Arc<AtomicBool>,
|
||||
experimental_api_enabled: Arc<AtomicBool>,
|
||||
opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
|
||||
disconnect_sender: Option<CancellationToken>,
|
||||
) -> Self {
|
||||
Self {
|
||||
initialized,
|
||||
experimental_api_enabled,
|
||||
opted_out_notification_methods,
|
||||
writer,
|
||||
disconnect_sender,
|
||||
}
|
||||
}
|
||||
|
||||
fn can_disconnect(&self) -> bool {
|
||||
self.disconnect_sender.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn request_disconnect(&self) {
|
||||
if let Some(disconnect_sender) = &self.disconnect_sender {
|
||||
disconnect_sender.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_skip_notification_for_connection(
|
||||
connection_state: &OutboundConnectionState,
|
||||
message: &OutgoingMessage,
|
||||
) -> bool {
|
||||
let Ok(opted_out_notification_methods) = connection_state.opted_out_notification_methods.read()
|
||||
else {
|
||||
warn!("failed to read outbound opted-out notifications");
|
||||
return false;
|
||||
};
|
||||
match message {
|
||||
OutgoingMessage::AppServerNotification(notification) => {
|
||||
if notification.experimental_reason().is_some()
|
||||
&& !connection_state
|
||||
.experimental_api_enabled
|
||||
.load(Ordering::Acquire)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let method = notification.to_string();
|
||||
opted_out_notification_methods.contains(method.as_str())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect_connection(
|
||||
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
|
||||
connection_id: ConnectionId,
|
||||
) -> bool {
|
||||
if let Some(connection_state) = connections.remove(&connection_id) {
|
||||
connection_state.request_disconnect();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn send_message_to_connection(
|
||||
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
|
||||
connection_id: ConnectionId,
|
||||
message: OutgoingMessage,
|
||||
write_complete_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
) -> bool {
|
||||
let Some(connection_state) = connections.get(&connection_id) else {
|
||||
warn!("dropping message for disconnected connection: {connection_id:?}");
|
||||
return false;
|
||||
};
|
||||
let message = filter_outgoing_message_for_connection(connection_state, message);
|
||||
if should_skip_notification_for_connection(connection_state, &message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let writer = connection_state.writer.clone();
|
||||
let queued_message = QueuedOutgoingMessage {
|
||||
message,
|
||||
write_complete_tx,
|
||||
};
|
||||
if connection_state.can_disconnect() {
|
||||
match writer.try_send(queued_message) {
|
||||
Ok(()) => false,
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
warn!(
|
||||
"disconnecting slow connection after outbound queue filled: {connection_id:?}"
|
||||
);
|
||||
disconnect_connection(connections, connection_id)
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
disconnect_connection(connections, connection_id)
|
||||
}
|
||||
}
|
||||
} else if writer.send(queued_message).await.is_err() {
|
||||
disconnect_connection(connections, connection_id)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_outgoing_message_for_connection(
|
||||
connection_state: &OutboundConnectionState,
|
||||
message: OutgoingMessage,
|
||||
) -> OutgoingMessage {
|
||||
let experimental_api_enabled = connection_state
|
||||
.experimental_api_enabled
|
||||
.load(Ordering::Acquire);
|
||||
match message {
|
||||
OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
|
||||
request_id,
|
||||
mut params,
|
||||
}) => {
|
||||
if !experimental_api_enabled {
|
||||
params.strip_experimental_fields();
|
||||
}
|
||||
OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
|
||||
request_id,
|
||||
params,
|
||||
})
|
||||
}
|
||||
_ => message,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn route_outgoing_envelope(
|
||||
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
|
||||
envelope: OutgoingEnvelope,
|
||||
) {
|
||||
match envelope {
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
write_complete_tx,
|
||||
} => {
|
||||
let _ =
|
||||
send_message_to_connection(connections, connection_id, message, write_complete_tx)
|
||||
.await;
|
||||
}
|
||||
OutgoingEnvelope::Broadcast { message } => {
|
||||
let target_connections: Vec<ConnectionId> = connections
|
||||
.iter()
|
||||
.filter_map(|(connection_id, connection_state)| {
|
||||
if connection_state.initialized.load(Ordering::Acquire)
|
||||
&& !should_skip_notification_for_connection(connection_state, &message)
|
||||
{
|
||||
Some(*connection_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for connection_id in target_connections {
|
||||
let _ = send_message_to_connection(
|
||||
connections,
|
||||
connection_id,
|
||||
message.clone(),
|
||||
/*write_complete_tx*/ None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "transport_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ThreadGoal;
|
||||
use codex_app_server_protocol::ThreadGoalStatus;
|
||||
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
fn absolute_path(path: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::from_absolute_path(path).expect("absolute path")
|
||||
}
|
||||
|
||||
fn thread_goal_updated_notification() -> ServerNotification {
|
||||
ServerNotification::ThreadGoalUpdated(ThreadGoalUpdatedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: None,
|
||||
goal: ThreadGoal {
|
||||
thread_id: "thread-1".to_string(),
|
||||
objective: "ship goal mode".to_string(),
|
||||
status: ThreadGoalStatus::Active,
|
||||
token_budget: None,
|
||||
tokens_used: 0,
|
||||
time_used_seconds: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_connection_notification_respects_opt_out_filters() {
|
||||
let connection_id = ConnectionId(7);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
let initialized = Arc::new(AtomicBool::new(true));
|
||||
let opted_out_notification_methods =
|
||||
Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()])));
|
||||
|
||||
let mut connections = HashMap::new();
|
||||
connections.insert(
|
||||
connection_id,
|
||||
OutboundConnectionState::new(
|
||||
writer_tx,
|
||||
initialized,
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
opted_out_notification_methods,
|
||||
/*disconnect_sender*/ None,
|
||||
),
|
||||
);
|
||||
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification {
|
||||
summary: "task_started".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
},
|
||||
)),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
writer_rx.try_recv().is_err(),
|
||||
"opted-out notification should be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_connection_notifications_are_dropped_for_opted_out_clients() {
|
||||
let connection_id = ConnectionId(10);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
|
||||
let mut connections = HashMap::new();
|
||||
connections.insert(
|
||||
connection_id,
|
||||
OutboundConnectionState::new(
|
||||
writer_tx,
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))),
|
||||
/*disconnect_sender*/ None,
|
||||
),
|
||||
);
|
||||
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification {
|
||||
summary: "task_started".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
},
|
||||
)),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
writer_rx.try_recv().is_err(),
|
||||
"opted-out notifications should not reach clients"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_connection_notifications_are_preserved_for_non_opted_out_clients() {
|
||||
let connection_id = ConnectionId(11);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
|
||||
let mut connections = HashMap::new();
|
||||
connections.insert(
|
||||
connection_id,
|
||||
OutboundConnectionState::new(
|
||||
writer_tx,
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
/*disconnect_sender*/ None,
|
||||
),
|
||||
);
|
||||
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification {
|
||||
summary: "task_started".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
},
|
||||
)),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let message = writer_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("notification should reach non-opted-out clients");
|
||||
assert!(matches!(
|
||||
message.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification { summary, .. }
|
||||
)) if summary == "task_started"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_notifications_are_dropped_without_capability() {
|
||||
let connection_id = ConnectionId(12);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
|
||||
let mut connections = HashMap::new();
|
||||
connections.insert(
|
||||
connection_id,
|
||||
OutboundConnectionState::new(
|
||||
writer_tx,
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
/*disconnect_sender*/ None,
|
||||
),
|
||||
);
|
||||
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
writer_rx.try_recv().is_err(),
|
||||
"experimental notifications should not reach clients without capability"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_notifications_are_preserved_with_capability() {
|
||||
let connection_id = ConnectionId(13);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
|
||||
let mut connections = HashMap::new();
|
||||
connections.insert(
|
||||
connection_id,
|
||||
OutboundConnectionState::new(
|
||||
writer_tx,
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
/*disconnect_sender*/ None,
|
||||
),
|
||||
);
|
||||
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let message = writer_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("experimental notification should reach opted-in client");
|
||||
assert!(matches!(
|
||||
message.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_execution_request_approval_strips_additional_permissions_without_capability() {
|
||||
let connection_id = ConnectionId(8);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
|
||||
let mut connections = HashMap::new();
|
||||
connections.insert(
|
||||
connection_id,
|
||||
OutboundConnectionState::new(
|
||||
writer_tx,
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
/*disconnect_sender*/ None,
|
||||
),
|
||||
);
|
||||
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: codex_app_server_protocol::CommandExecutionRequestApprovalParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
turn_id: "turn_123".to_string(),
|
||||
item_id: "call_123".to_string(),
|
||||
approval_id: None,
|
||||
reason: Some("Need extra read access".to_string()),
|
||||
network_approval_context: None,
|
||||
command: Some("cat file".to_string()),
|
||||
cwd: Some(absolute_path("/tmp")),
|
||||
command_actions: None,
|
||||
additional_permissions: Some(
|
||||
codex_app_server_protocol::AdditionalPermissionProfile {
|
||||
network: None,
|
||||
file_system: Some(
|
||||
codex_app_server_protocol::AdditionalFileSystemPermissions {
|
||||
read: Some(vec![absolute_path("/tmp/allowed")]),
|
||||
write: None,
|
||||
glob_scan_max_depth: None,
|
||||
entries: None,
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
available_decisions: None,
|
||||
},
|
||||
}),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let message = writer_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("request should be delivered to the connection");
|
||||
let json = serde_json::to_value(message.message).expect("request should serialize");
|
||||
assert_eq!(json["params"].get("additionalPermissions"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_execution_request_approval_keeps_additional_permissions_with_capability() {
|
||||
let connection_id = ConnectionId(9);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
|
||||
let mut connections = HashMap::new();
|
||||
connections.insert(
|
||||
connection_id,
|
||||
OutboundConnectionState::new(
|
||||
writer_tx,
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
/*disconnect_sender*/ None,
|
||||
),
|
||||
);
|
||||
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: codex_app_server_protocol::CommandExecutionRequestApprovalParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
turn_id: "turn_123".to_string(),
|
||||
item_id: "call_123".to_string(),
|
||||
approval_id: None,
|
||||
reason: Some("Need extra read access".to_string()),
|
||||
network_approval_context: None,
|
||||
command: Some("cat file".to_string()),
|
||||
cwd: Some(absolute_path("/tmp")),
|
||||
command_actions: None,
|
||||
additional_permissions: Some(
|
||||
codex_app_server_protocol::AdditionalPermissionProfile {
|
||||
network: None,
|
||||
file_system: Some(
|
||||
codex_app_server_protocol::AdditionalFileSystemPermissions {
|
||||
read: Some(vec![absolute_path("/tmp/allowed")]),
|
||||
write: None,
|
||||
glob_scan_max_depth: None,
|
||||
entries: None,
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
available_decisions: None,
|
||||
},
|
||||
}),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let message = writer_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("request should be delivered to the connection");
|
||||
let json = serde_json::to_value(message.message).expect("request should serialize");
|
||||
let allowed_path = absolute_path("/tmp/allowed").to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
json["params"]["additionalPermissions"],
|
||||
json!({
|
||||
"network": null,
|
||||
"fileSystem": {
|
||||
"read": [allowed_path],
|
||||
"write": null,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broadcast_does_not_block_on_slow_connection() {
|
||||
let fast_connection_id = ConnectionId(1);
|
||||
let slow_connection_id = ConnectionId(2);
|
||||
|
||||
let (fast_writer_tx, mut fast_writer_rx) = mpsc::channel(1);
|
||||
let (slow_writer_tx, mut slow_writer_rx) = mpsc::channel(1);
|
||||
let fast_disconnect_token = CancellationToken::new();
|
||||
let slow_disconnect_token = CancellationToken::new();
|
||||
|
||||
let mut connections = HashMap::new();
|
||||
connections.insert(
|
||||
fast_connection_id,
|
||||
OutboundConnectionState::new(
|
||||
fast_writer_tx,
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
Some(fast_disconnect_token.clone()),
|
||||
),
|
||||
);
|
||||
connections.insert(
|
||||
slow_connection_id,
|
||||
OutboundConnectionState::new(
|
||||
slow_writer_tx.clone(),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
Some(slow_disconnect_token.clone()),
|
||||
),
|
||||
);
|
||||
|
||||
let queued_message = OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification {
|
||||
summary: "already-buffered".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
},
|
||||
));
|
||||
slow_writer_tx
|
||||
.try_send(QueuedOutgoingMessage::new(queued_message))
|
||||
.expect("channel should have room");
|
||||
|
||||
let broadcast_message = OutgoingMessage::AppServerNotification(
|
||||
ServerNotification::ConfigWarning(ConfigWarningNotification {
|
||||
summary: "test".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
}),
|
||||
);
|
||||
timeout(
|
||||
Duration::from_millis(100),
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::Broadcast {
|
||||
message: broadcast_message,
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("broadcast should return even when one connection is slow");
|
||||
assert!(!connections.contains_key(&slow_connection_id));
|
||||
assert!(slow_disconnect_token.is_cancelled());
|
||||
assert!(!fast_disconnect_token.is_cancelled());
|
||||
let fast_message = fast_writer_rx
|
||||
.try_recv()
|
||||
.expect("fast connection should receive the broadcast notification");
|
||||
assert!(matches!(
|
||||
fast_message.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification { summary, .. }
|
||||
)) if summary == "test"
|
||||
));
|
||||
|
||||
let slow_message = slow_writer_rx
|
||||
.try_recv()
|
||||
.expect("slow connection should retain its original buffered message");
|
||||
assert!(matches!(
|
||||
slow_message.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification { summary, .. }
|
||||
)) if summary == "already-buffered"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_connection_stdio_waits_instead_of_disconnecting_when_writer_queue_is_full() {
|
||||
let connection_id = ConnectionId(3);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
writer_tx
|
||||
.send(QueuedOutgoingMessage::new(
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification {
|
||||
summary: "queued".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
},
|
||||
)),
|
||||
))
|
||||
.await
|
||||
.expect("channel should accept the first queued message");
|
||||
|
||||
let mut connections = HashMap::new();
|
||||
connections.insert(
|
||||
connection_id,
|
||||
OutboundConnectionState::new(
|
||||
writer_tx,
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
/*disconnect_sender*/ None,
|
||||
),
|
||||
);
|
||||
|
||||
let route_task = tokio::spawn(async move {
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification {
|
||||
summary: "second".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
},
|
||||
)),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
let first = timeout(Duration::from_millis(100), writer_rx.recv())
|
||||
.await
|
||||
.expect("first queued message should be readable")
|
||||
.expect("first queued message should exist");
|
||||
timeout(Duration::from_millis(100), route_task)
|
||||
.await
|
||||
.expect("routing should finish after the first queued message is drained")
|
||||
.expect("routing task should succeed");
|
||||
|
||||
assert!(matches!(
|
||||
first.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification { summary, .. }
|
||||
)) if summary == "queued"
|
||||
));
|
||||
let second = writer_rx
|
||||
.try_recv()
|
||||
.expect("second notification should be delivered once the queue has room");
|
||||
assert!(matches!(
|
||||
second.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification { summary, .. }
|
||||
)) if summary == "second"
|
||||
));
|
||||
}
|
||||
@@ -59,6 +59,7 @@ use codex_app_server_protocol::ModelProviderCapabilitiesReadParams;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginSkillReadParams;
|
||||
use codex_app_server_protocol::PluginUninstallParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
@@ -660,6 +661,15 @@ impl McpProcess {
|
||||
self.send_request("plugin/read", params).await
|
||||
}
|
||||
|
||||
/// Send a `plugin/skill/read` JSON-RPC request.
|
||||
pub async fn send_plugin_skill_read_request(
|
||||
&mut self,
|
||||
params: PluginSkillReadParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("plugin/skill/read", params).await
|
||||
}
|
||||
|
||||
/// Send an `mcpServerStatus/list` JSON-RPC request.
|
||||
pub async fn send_list_mcp_server_status_request(
|
||||
&mut self,
|
||||
|
||||
@@ -13,10 +13,16 @@ use axum::Router;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::McpElicitationSchema;
|
||||
use codex_app_server_protocol::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestResponse;
|
||||
use codex_app_server_protocol::McpServerToolCallParams;
|
||||
use codex_app_server_protocol::McpServerToolCallResponse;
|
||||
use codex_app_server_protocol::McpToolCallStatus;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
@@ -27,12 +33,17 @@ use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::BooleanSchema;
|
||||
use rmcp::model::CallToolRequestParams;
|
||||
use rmcp::model::CallToolResult;
|
||||
use rmcp::model::Content;
|
||||
use rmcp::model::CreateElicitationRequestParams;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use rmcp::model::ElicitationSchema;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::ListToolsResult;
|
||||
use rmcp::model::Meta;
|
||||
use rmcp::model::PrimitiveSchema;
|
||||
use rmcp::model::ServerCapabilities;
|
||||
use rmcp::model::ServerInfo;
|
||||
use rmcp::model::Tool;
|
||||
@@ -52,6 +63,8 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const TEST_SERVER_NAME: &str = "tool_server";
|
||||
const TEST_TOOL_NAME: &str = "echo_tool";
|
||||
const LARGE_RESPONSE_MESSAGE: &str = "large";
|
||||
const ELICITATION_TRIGGER_MESSAGE: &str = "confirm";
|
||||
const ELICITATION_MESSAGE: &str = "Allow this request?";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mcp_server_tool_call_returns_tool_result() -> Result<()> {
|
||||
@@ -171,6 +184,116 @@ async fn mcp_server_tool_call_returns_error_for_unknown_thread() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mcp_server_tool_call_round_trips_elicitation() -> Result<()> {
|
||||
let responses_server = responses::start_mock_server().await;
|
||||
let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?;
|
||||
let codex_home = TempDir::new()?;
|
||||
write_mock_responses_config_toml(
|
||||
codex_home.path(),
|
||||
&responses_server.uri(),
|
||||
&BTreeMap::new(),
|
||||
/*auto_compact_limit*/ 1024,
|
||||
/*requires_openai_auth*/ None,
|
||||
"mock_provider",
|
||||
"compact",
|
||||
)?;
|
||||
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let mut config_toml = std::fs::read_to_string(&config_path)?;
|
||||
config_toml.push_str(&format!(
|
||||
r#"
|
||||
[mcp_servers.{TEST_SERVER_NAME}]
|
||||
url = "{mcp_server_url}/mcp"
|
||||
"#
|
||||
));
|
||||
std::fs::write(config_path, config_toml)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
|
||||
|
||||
let tool_call_request_id = mcp
|
||||
.send_mcp_server_tool_call_request(McpServerToolCallParams {
|
||||
thread_id: thread.id.clone(),
|
||||
server: TEST_SERVER_NAME.to_string(),
|
||||
tool: TEST_TOOL_NAME.to_string(),
|
||||
arguments: Some(json!({
|
||||
"message": ELICITATION_TRIGGER_MESSAGE,
|
||||
})),
|
||||
meta: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let server_req = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else {
|
||||
panic!("expected McpServerElicitationRequest request, got: {server_req:?}");
|
||||
};
|
||||
let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value(
|
||||
ElicitationSchema::builder()
|
||||
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
|
||||
.build()
|
||||
.map_err(anyhow::Error::msg)?,
|
||||
)?)?;
|
||||
assert_eq!(
|
||||
params,
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: thread.id,
|
||||
turn_id: None,
|
||||
server_name: TEST_SERVER_NAME.to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta: None,
|
||||
message: ELICITATION_MESSAGE.to_string(),
|
||||
requested_schema,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
mcp.send_response(
|
||||
request_id,
|
||||
serde_json::to_value(McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Accept,
|
||||
content: Some(json!({
|
||||
"confirmed": true,
|
||||
})),
|
||||
meta: None,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let tool_call_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: McpServerToolCallResponse = to_response(tool_call_response)?;
|
||||
assert_eq!(response.content.len(), 1);
|
||||
assert_eq!(response.content[0].get("type"), Some(&json!("text")));
|
||||
assert_eq!(response.content[0].get("text"), Some(&json!("accepted")));
|
||||
|
||||
mcp_server_handle.abort();
|
||||
let _ = mcp_server_handle.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> {
|
||||
let call_id = "call-large-mcp";
|
||||
@@ -375,6 +498,36 @@ impl ServerHandler for ToolAppsMcpServer {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if message == ELICITATION_TRIGGER_MESSAGE {
|
||||
let requested_schema = ElicitationSchema::builder()
|
||||
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
|
||||
.build()
|
||||
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
|
||||
let result = context
|
||||
.peer
|
||||
.create_elicitation(CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta: None,
|
||||
message: ELICITATION_MESSAGE.to_string(),
|
||||
requested_schema,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
|
||||
let output = match result.action {
|
||||
ElicitationAction::Accept => {
|
||||
assert_eq!(
|
||||
result.content,
|
||||
Some(json!({
|
||||
"confirmed": true,
|
||||
}))
|
||||
);
|
||||
"accepted"
|
||||
}
|
||||
ElicitationAction::Decline => "declined",
|
||||
ElicitationAction::Cancel => "cancelled",
|
||||
};
|
||||
return Ok(CallToolResult::success(vec![Content::text(output)]));
|
||||
}
|
||||
|
||||
let mut result = CallToolResult::structured(json!({
|
||||
"echoed": message,
|
||||
"threadId": thread_id,
|
||||
|
||||
@@ -22,6 +22,8 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginSkillReadParams;
|
||||
use codex_app_server_protocol::PluginSkillReadResponse;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
@@ -139,6 +141,7 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled()
|
||||
.message
|
||||
.contains("remote plugin read is not enabled")
|
||||
);
|
||||
assert!(err.error.message.contains("chatgpt-global"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -252,7 +255,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
|
||||
remote_marketplace_name: Some("chatgpt-global".to_string()),
|
||||
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -286,6 +289,70 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let skill_body = r##"{
|
||||
"plugin_id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"status": "ENABLED",
|
||||
"plugin_release_id": "release-1",
|
||||
"name": "plan-work",
|
||||
"description": "Plan work from Linear issues",
|
||||
"plugin_release_skill_id": "skill-1",
|
||||
"skill_md_contents": "# Plan Work\n\nUse Linear issues to create a plan."
|
||||
}"##;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(
|
||||
"/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000/skills/plan-work",
|
||||
))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(skill_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_skill_read_request(PluginSkillReadParams {
|
||||
remote_marketplace_name: "chatgpt-global".to_string(),
|
||||
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
skill_name: "plan-work".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginSkillReadResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginSkillReadResponse {
|
||||
contents: Some("# Plan Work\n\nUse Linear issues to create a plan.".to_string()),
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -412,6 +479,11 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(err.error.message.contains("invalid remote plugin id"));
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInterface;
|
||||
use codex_app_server_protocol::PluginShareDeleteResponse;
|
||||
use codex_app_server_protocol::PluginShareListItem;
|
||||
use codex_app_server_protocol::PluginShareListResponse;
|
||||
use codex_app_server_protocol::PluginShareSaveResponse;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
@@ -49,6 +50,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_corrupt_plugin_share_local_path_mapping(codex_home.path())?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/public/plugins/workspace/upload-url"))
|
||||
@@ -88,11 +90,12 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path.clone())?;
|
||||
let request_id = mcp
|
||||
.send_raw_request(
|
||||
"plugin/share/save",
|
||||
Some(json!({
|
||||
"pluginPath": AbsolutePathBuf::try_from(plugin_path)?,
|
||||
"pluginPath": expected_plugin_path.clone(),
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
@@ -111,6 +114,62 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
}
|
||||
);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/workspace/created"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", "WORKSPACE"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [installed_remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request("plugin/share/list", Some(json!({})))
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginShareListResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginShareListResponse {
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
local_plugin_path: Some(expected_plugin_path),
|
||||
}],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -169,16 +228,20 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginShareListResponse {
|
||||
data: vec![PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
local_plugin_path: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
@@ -198,6 +261,8 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
let local_plugin_path = AbsolutePathBuf::try_from(codex_home.path().join("local-plugin"))?;
|
||||
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path)?;
|
||||
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path("/backend-api/public/plugins/workspace/plugins_123"))
|
||||
@@ -227,6 +292,62 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
|
||||
let response: PluginShareDeleteResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response, PluginShareDeleteResponse {});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/workspace/created"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", "WORKSPACE"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [installed_remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request("plugin/share/list", Some(json!({})))
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginShareListResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginShareListResponse {
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
local_plugin_path: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -250,6 +371,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
|
||||
"id": plugin_id,
|
||||
"name": "demo-plugin",
|
||||
"scope": "WORKSPACE",
|
||||
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {
|
||||
@@ -315,6 +437,33 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result<PathBuf>
|
||||
Ok(plugin_path)
|
||||
}
|
||||
|
||||
fn write_corrupt_plugin_share_local_path_mapping(codex_home: &Path) -> std::io::Result<()> {
|
||||
write_file(
|
||||
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
||||
"not-json",
|
||||
)
|
||||
}
|
||||
|
||||
fn write_plugin_share_local_path_mapping(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
plugin_path: &AbsolutePathBuf,
|
||||
) -> std::io::Result<()> {
|
||||
let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new();
|
||||
local_plugin_paths_by_remote_plugin_id.insert(
|
||||
remote_plugin_id.to_string(),
|
||||
serde_json::to_value(plugin_path).map_err(std::io::Error::other)?,
|
||||
);
|
||||
let contents = serde_json::to_string_pretty(&json!({
|
||||
"localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id,
|
||||
}))
|
||||
.map_err(std::io::Error::other)?;
|
||||
write_file(
|
||||
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
||||
&format!("{contents}\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
let Some(parent) = path.parent() else {
|
||||
return Err(std::io::Error::other(format!(
|
||||
|
||||
@@ -48,6 +48,7 @@ use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource as ProtocolSessionSource;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
@@ -56,6 +57,7 @@ use codex_thread_store::CreateThreadParams;
|
||||
use codex_thread_store::InMemoryThreadStore;
|
||||
use codex_thread_store::ThreadEventPersistenceMode;
|
||||
use codex_thread_store::ThreadMetadataPatch;
|
||||
use codex_thread_store::ThreadPersistenceMetadata;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use codex_thread_store::UpdateThreadMetadataParams;
|
||||
use core_test_support::responses;
|
||||
@@ -337,6 +339,88 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_path() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let store_id = Uuid::new_v4().to_string();
|
||||
create_config_toml_with_thread_store(codex_home.path(), &store_id)?;
|
||||
let store = InMemoryThreadStore::for_id(store_id.clone());
|
||||
let _in_memory_store = InMemoryThreadStoreId { store_id };
|
||||
|
||||
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.loader_overrides(loader_overrides.clone())
|
||||
.build()
|
||||
.await?;
|
||||
let client = in_process::start(InProcessStartArgs {
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config: Arc::new(config),
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
config_warnings: Vec::new(),
|
||||
session_source: SessionSource::Cli.into(),
|
||||
enable_codex_api_key_env: false,
|
||||
initialize: InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: "codex-app-server-tests".to_string(),
|
||||
title: None,
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let result = client
|
||||
.request(ClientRequest::ThreadStart {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect("thread/start should succeed");
|
||||
let ThreadStartResponse { thread, .. } = serde_json::from_value(result)?;
|
||||
assert_eq!(thread.path, None);
|
||||
|
||||
let thread_id = codex_protocol::ThreadId::from_string(&thread.id)?;
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id,
|
||||
items: store_history_items(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let result = client
|
||||
.request(ClientRequest::ThreadRead {
|
||||
request_id: RequestId::Integer(2),
|
||||
params: ThreadReadParams {
|
||||
thread_id: thread.id,
|
||||
include_turns: true,
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect("thread/read should succeed");
|
||||
let ThreadReadResponse { thread, .. } = serde_json::from_value(result)?;
|
||||
|
||||
assert_eq!(turn_user_texts(&thread.turns), vec!["history from store"]);
|
||||
|
||||
client.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1028,6 +1112,11 @@ async fn seed_pathless_store_thread(
|
||||
source: ProtocolSessionSource::Cli,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
dynamic_tools: Vec::new(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: None,
|
||||
model_provider: "test-provider".to_string(),
|
||||
memory_mode: ThreadMemoryMode::Disabled,
|
||||
},
|
||||
event_persistence_mode: ThreadEventPersistenceMode::default(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -71,6 +71,7 @@ use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::fs::FileTimes;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
@@ -1669,7 +1670,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Result<()> {
|
||||
async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let first_body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
@@ -1749,24 +1750,71 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul
|
||||
)
|
||||
.await??;
|
||||
|
||||
let resume_id = primary
|
||||
let other_thread_id = ThreadId::new().to_string();
|
||||
let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &thread_id);
|
||||
std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?;
|
||||
let thread_uuid = Uuid::parse_str(&thread_id)?;
|
||||
let mut stale_file = std::fs::File::create(&stale_path)?;
|
||||
let stale_meta = json!({
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": thread_uuid,
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"cwd": codex_home.path(),
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "cli",
|
||||
"model_provider": "test-provider",
|
||||
},
|
||||
});
|
||||
writeln!(stale_file, "{stale_meta}")?;
|
||||
let stale_user_event = json!({
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "user_message",
|
||||
"message": "stale history",
|
||||
"kind": "plain",
|
||||
},
|
||||
});
|
||||
writeln!(stale_file, "{stale_user_event}")?;
|
||||
|
||||
let stale_resume_id = primary
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: thread_id.clone(),
|
||||
path: Some(PathBuf::from("/tmp/does-not-match-running-rollout.jsonl")),
|
||||
thread_id: other_thread_id.clone(),
|
||||
path: Some(stale_path),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let resume_err: JSONRPCError = timeout(
|
||||
let stale_resume_err: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
primary.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
||||
primary.read_stream_until_error_message(RequestId::Integer(stale_resume_id)),
|
||||
)
|
||||
.await??;
|
||||
assert!(
|
||||
resume_err.error.message.contains("mismatched path"),
|
||||
stale_resume_err.error.message.contains("stale path"),
|
||||
"unexpected resume error: {}",
|
||||
resume_err.error.message
|
||||
stale_resume_err.error.message
|
||||
);
|
||||
|
||||
let resume_by_path_id = primary
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: other_thread_id.clone(),
|
||||
path: thread.path,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let resume_by_path_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
primary.read_stream_until_response_message(RequestId::Integer(resume_by_path_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadResumeResponse {
|
||||
thread: resumed, ..
|
||||
} = to_response::<ThreadResumeResponse>(resume_by_path_resp)?;
|
||||
assert_eq!(resumed.id, thread_id);
|
||||
|
||||
primary
|
||||
.interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
@@ -2463,7 +2511,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
|
||||
async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
@@ -2523,13 +2571,6 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
|
||||
thread: resumed, ..
|
||||
} = to_response::<ThreadResumeResponse>(resume_resp)?;
|
||||
assert_eq!(resumed.id, thread.id);
|
||||
let resumed_path = resumed.path.as_ref().expect("resumed thread path");
|
||||
let original_path = thread.path.as_ref().expect("original thread path");
|
||||
assert_eq!(
|
||||
normalized_existing_path(resumed_path)?,
|
||||
normalized_existing_path(original_path)?
|
||||
);
|
||||
assert_eq!(resumed.status, ThreadStatus::Idle);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,11 +12,10 @@ use super::callbacks::yield_control_callback;
|
||||
|
||||
pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), String> {
|
||||
let global = scope.get_current_context().global(scope);
|
||||
let console = v8::String::new(scope, "console")
|
||||
.ok_or_else(|| "failed to allocate global `console`".to_string())?;
|
||||
if global.delete(scope, console.into()) != Some(true) {
|
||||
return Err("failed to remove global `console`".to_string());
|
||||
}
|
||||
delete_global(scope, global, "console")?;
|
||||
delete_global(scope, global, "Atomics")?;
|
||||
delete_global(scope, global, "SharedArrayBuffer")?;
|
||||
delete_global(scope, global, "WebAssembly")?;
|
||||
|
||||
let tools = build_tools_object(scope)?;
|
||||
let all_tools = build_all_tools_value(scope)?;
|
||||
@@ -142,3 +141,17 @@ fn set_global<'s>(
|
||||
Err(format!("failed to set global `{name}`"))
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_global<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
global: v8::Local<'s, v8::Object>,
|
||||
name: &str,
|
||||
) -> Result<(), String> {
|
||||
let key = v8::String::new(scope, name)
|
||||
.ok_or_else(|| format!("failed to allocate global `{name}`"))?;
|
||||
if global.delete(scope, key.into()) == Some(true) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("failed to remove global `{name}`"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ use pretty_assertions::assert_eq;
|
||||
use rmcp::model::CreateElicitationRequestParams;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use rmcp::model::ElicitationCapability;
|
||||
use rmcp::model::FormElicitationCapability;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Meta;
|
||||
use rmcp::model::NumberOrString;
|
||||
@@ -845,18 +844,14 @@ async fn host_owned_codex_apps_server_is_identified_by_client_provenance() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elicitation_capability_enabled_for_custom_servers() {
|
||||
fn elicitation_capability_uses_2025_06_18_shape_for_all_servers() {
|
||||
for server_name in [CODEX_APPS_MCP_SERVER_NAME, "custom_mcp"] {
|
||||
let capability = elicitation_capability_for_server(server_name);
|
||||
assert!(matches!(
|
||||
capability,
|
||||
Some(ElicitationCapability {
|
||||
form: Some(FormElicitationCapability {
|
||||
schema_validation: None
|
||||
}),
|
||||
url: None,
|
||||
})
|
||||
));
|
||||
assert_eq!(capability, Some(ElicitationCapability::default()));
|
||||
assert_eq!(
|
||||
serde_json::to_value(capability).expect("serialize elicitation capability"),
|
||||
serde_json::json!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ use futures::future::FutureExt;
|
||||
use futures::future::Shared;
|
||||
use rmcp::model::ClientCapabilities;
|
||||
use rmcp::model::ElicitationCapability;
|
||||
use rmcp::model::FormElicitationCapability;
|
||||
use rmcp::model::Implementation;
|
||||
use rmcp::model::InitializeRequestParams;
|
||||
use rmcp::model::ProtocolVersion;
|
||||
@@ -329,12 +328,7 @@ pub(crate) fn elicitation_capability_for_server(
|
||||
) -> Option<ElicitationCapability> {
|
||||
// https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities
|
||||
// indicates this should be an empty object.
|
||||
Some(ElicitationCapability {
|
||||
form: Some(FormElicitationCapability {
|
||||
schema_validation: None,
|
||||
}),
|
||||
url: None,
|
||||
})
|
||||
Some(ElicitationCapability::default())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_tools_for_client_uncached(
|
||||
|
||||
@@ -65,6 +65,28 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [
|
||||
LMSTUDIO_OSS_PROVIDER_ID,
|
||||
];
|
||||
|
||||
pub const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024;
|
||||
|
||||
const fn default_allow_login_shell() -> Option<bool> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
fn default_history() -> Option<History> {
|
||||
Some(History::default())
|
||||
}
|
||||
|
||||
const fn default_project_doc_max_bytes() -> Option<usize> {
|
||||
Some(DEFAULT_PROJECT_DOC_MAX_BYTES)
|
||||
}
|
||||
|
||||
fn default_project_doc_fallback_filenames() -> Option<Vec<String>> {
|
||||
Some(Vec::new())
|
||||
}
|
||||
|
||||
const fn default_hide_agent_reasoning() -> Option<bool> {
|
||||
Some(false)
|
||||
}
|
||||
|
||||
/// Base config deserialized from ~/.codex/config.toml.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
@@ -106,6 +128,7 @@ pub struct ConfigToml {
|
||||
/// If `false`, the model can never use a login shell: `login = true`
|
||||
/// requests are rejected, and omitting `login` defaults to a non-login
|
||||
/// shell.
|
||||
#[serde(default = "default_allow_login_shell")]
|
||||
pub allow_login_shell: Option<bool>,
|
||||
|
||||
/// Sandbox mode to use.
|
||||
@@ -123,7 +146,7 @@ pub struct ConfigToml {
|
||||
#[serde(default)]
|
||||
pub permissions: Option<PermissionsToml>,
|
||||
|
||||
/// Optional external command to spawn for end-user notifications.
|
||||
/// Deprecated optional external command to spawn for end-user notifications.
|
||||
#[serde(default)]
|
||||
pub notify: Option<Vec<String>>,
|
||||
|
||||
@@ -202,9 +225,11 @@ pub struct ConfigToml {
|
||||
pub model_providers: HashMap<String, ModelProviderInfo>,
|
||||
|
||||
/// Maximum number of bytes to include from an AGENTS.md project doc file.
|
||||
#[serde(default = "default_project_doc_max_bytes")]
|
||||
pub project_doc_max_bytes: Option<usize>,
|
||||
|
||||
/// Ordered list of fallback filenames to look for when AGENTS.md is missing.
|
||||
#[serde(default = "default_project_doc_fallback_filenames")]
|
||||
pub project_doc_fallback_filenames: Option<Vec<String>>,
|
||||
|
||||
/// Token budget applied when storing tool/function outputs in the context manager.
|
||||
@@ -233,7 +258,7 @@ pub struct ConfigToml {
|
||||
pub profiles: HashMap<String, ConfigProfile>,
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_history")]
|
||||
pub history: Option<History>,
|
||||
|
||||
/// Directory where Codex stores the SQLite state DB.
|
||||
@@ -244,6 +269,9 @@ pub struct ConfigToml {
|
||||
/// Defaults to `$CODEX_HOME/log`.
|
||||
pub log_dir: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Debugging and reproducibility settings.
|
||||
pub debug: Option<DebugToml>,
|
||||
|
||||
/// Optional URI-based file opener. If set, citations to files in the model
|
||||
/// output will be hyperlinked using the specified URI scheme.
|
||||
pub file_opener: Option<UriBasedFileOpener>,
|
||||
@@ -253,6 +281,7 @@ pub struct ConfigToml {
|
||||
|
||||
/// When set to `true`, `AgentReasoning` events will be hidden from the
|
||||
/// UI/output. Defaults to `false`.
|
||||
#[serde(default = "default_hide_agent_reasoning")]
|
||||
pub hide_agent_reasoning: Option<bool>,
|
||||
|
||||
/// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output.
|
||||
@@ -420,6 +449,38 @@ pub struct ConfigToml {
|
||||
pub oss_provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ConfigLockfileToml {
|
||||
pub version: u32,
|
||||
pub codex_version: String,
|
||||
|
||||
/// Replayable effective config captured in the lockfile.
|
||||
pub config: ConfigToml,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DebugToml {
|
||||
pub config_lockfile: Option<DebugConfigLockToml>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DebugConfigLockToml {
|
||||
/// Directory where Codex writes effective session config lock files.
|
||||
pub export_dir: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Lockfile to replay as the authoritative effective config.
|
||||
pub load_path: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Allow replaying a lock generated by a different Codex version.
|
||||
pub allow_codex_version_mismatch: Option<bool>,
|
||||
|
||||
/// Save fields resolved from the model catalog/session configuration.
|
||||
pub save_fields_resolved_from_model_catalog: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ThreadStoreToml {
|
||||
|
||||
@@ -137,6 +137,7 @@ impl UriBasedFileOpener {
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[serde(default)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct History {
|
||||
/// If true, history entries will not be written to disk.
|
||||
@@ -263,7 +264,7 @@ pub struct MemoriesToml {
|
||||
}
|
||||
|
||||
/// Effective memories settings after defaults are applied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct MemoriesConfig {
|
||||
pub disable_on_external_context: bool,
|
||||
pub generate_memories: bool,
|
||||
@@ -635,6 +636,11 @@ pub struct Tui {
|
||||
#[serde(default)]
|
||||
pub status_line: Option<Vec<String>>,
|
||||
|
||||
/// Color status line items with colors derived from the active syntax theme.
|
||||
/// Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub status_line_use_colors: bool,
|
||||
|
||||
/// Ordered list of terminal title item identifiers.
|
||||
///
|
||||
/// When set, the TUI renders the selected items into the terminal window/tab title.
|
||||
|
||||
@@ -387,8 +387,6 @@ pub struct PluginsManager {
|
||||
configured_marketplace_upgrade_state: RwLock<ConfiguredMarketplaceUpgradeState>,
|
||||
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
|
||||
cached_enabled_outcome: RwLock<Option<CachedPluginLoadOutcome>>,
|
||||
// TODO(remote plugins): reset this cache when ChatGPT auth/account state changes so stale
|
||||
// remote installed state cannot remain effective for a different account.
|
||||
remote_installed_plugins_cache: RwLock<Option<Vec<RemoteInstalledPlugin>>>,
|
||||
remote_installed_plugins_cache_refresh_state: RwLock<RemoteInstalledPluginsCacheRefreshState>,
|
||||
remote_sync_lock: Semaphore,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::store::PLUGINS_CACHE_DIR;
|
||||
use crate::store::PluginStore;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
@@ -8,6 +9,7 @@ use codex_app_server_protocol::SkillInterface;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use reqwest::RequestBuilder;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -16,6 +18,7 @@ use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
mod remote_installed_plugin_sync;
|
||||
mod share;
|
||||
@@ -39,6 +42,7 @@ pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Workspace P
|
||||
const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
|
||||
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePluginServiceConfig {
|
||||
@@ -72,6 +76,13 @@ pub struct RemotePluginSummary {
|
||||
pub interface: Option<PluginInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RemotePluginShareSummary {
|
||||
pub summary: RemotePluginSummary,
|
||||
pub share_url: Option<String>,
|
||||
pub local_plugin_path: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RemotePluginDetail {
|
||||
pub marketplace_name: String,
|
||||
@@ -93,6 +104,32 @@ pub struct RemotePluginSkill {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RemotePluginSkillDetail {
|
||||
pub contents: Option<String>,
|
||||
}
|
||||
|
||||
pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool {
|
||||
!plugin_id.is_empty()
|
||||
&& plugin_id
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
|
||||
}
|
||||
|
||||
pub fn validate_remote_plugin_id(plugin_id: &str) -> Result<(), JSONRPCErrorError> {
|
||||
if !is_valid_remote_plugin_id(plugin_id) {
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message:
|
||||
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed"
|
||||
.to_string(),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemotePluginCatalogError {
|
||||
#[error("chatgpt authentication required for remote plugin catalog")]
|
||||
@@ -127,11 +164,25 @@ pub enum RemotePluginCatalogError {
|
||||
source: serde_json::Error,
|
||||
},
|
||||
|
||||
#[error("invalid remote plugin catalog base URL: {0}")]
|
||||
InvalidBaseUrl(#[source] url::ParseError),
|
||||
|
||||
#[error("invalid remote plugin catalog base URL path")]
|
||||
InvalidBaseUrlPath,
|
||||
|
||||
#[error("remote marketplace `{marketplace_name}` is not supported")]
|
||||
UnknownMarketplace { marketplace_name: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
|
||||
)]
|
||||
UnexpectedPluginId { expected: String, actual: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin skill response returned unexpected skill name: expected `{expected}`, got `{actual}`"
|
||||
)]
|
||||
UnexpectedSkillName { expected: String, actual: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
|
||||
)]
|
||||
@@ -202,6 +253,14 @@ impl RemotePluginScope {
|
||||
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_marketplace_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global),
|
||||
REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
@@ -226,6 +285,13 @@ struct RemotePluginSkillResponse {
|
||||
interface: Option<RemotePluginSkillInterfaceResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginSkillDetailResponse {
|
||||
plugin_id: String,
|
||||
name: String,
|
||||
skill_md_contents: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginReleaseInterfaceResponse {
|
||||
short_description: Option<String>,
|
||||
@@ -265,6 +331,8 @@ struct RemotePluginDirectoryItem {
|
||||
id: String,
|
||||
name: String,
|
||||
scope: RemotePluginScope,
|
||||
#[serde(default)]
|
||||
share_url: Option<String>,
|
||||
installation_policy: PluginInstallPolicy,
|
||||
authentication_policy: PluginAuthPolicy,
|
||||
#[serde(rename = "status", default)]
|
||||
@@ -462,6 +530,42 @@ pub async fn fetch_remote_plugin_detail_with_download_urls(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_plugin_skill_detail(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
marketplace_name: &str,
|
||||
plugin_id: &str,
|
||||
skill_name: &str,
|
||||
) -> Result<RemotePluginSkillDetail, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
if RemotePluginScope::from_marketplace_name(marketplace_name).is_none() {
|
||||
return Err(RemotePluginCatalogError::UnknownMarketplace {
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let url = remote_plugin_skill_detail_url(config, plugin_id, skill_name)?;
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.get(&url), auth)?;
|
||||
let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?;
|
||||
if response.plugin_id != plugin_id {
|
||||
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
actual: response.plugin_id,
|
||||
});
|
||||
}
|
||||
if response.name != skill_name {
|
||||
return Err(RemotePluginCatalogError::UnexpectedSkillName {
|
||||
expected: skill_name.to_string(),
|
||||
actual: response.name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RemotePluginSkillDetail {
|
||||
contents: response.skill_md_contents,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_remote_plugin_detail_with_download_url_option(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
@@ -883,6 +987,27 @@ async fn fetch_plugin_detail(
|
||||
send_and_decode(request, &url).await
|
||||
}
|
||||
|
||||
fn remote_plugin_skill_detail_url(
|
||||
config: &RemotePluginServiceConfig,
|
||||
plugin_id: &str,
|
||||
skill_name: &str,
|
||||
) -> Result<String, RemotePluginCatalogError> {
|
||||
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
|
||||
.map_err(RemotePluginCatalogError::InvalidBaseUrl)?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|()| RemotePluginCatalogError::InvalidBaseUrlPath)?;
|
||||
segments.pop_if_empty();
|
||||
segments.push("ps");
|
||||
segments.push("plugins");
|
||||
segments.push(plugin_id);
|
||||
segments.push("skills");
|
||||
segments.push(skill_name);
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> {
|
||||
let Some(auth) = auth else {
|
||||
return Err(RemotePluginCatalogError::AuthRequired);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use reqwest::RequestBuilder;
|
||||
@@ -13,6 +14,9 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tracing::warn;
|
||||
|
||||
mod local_paths;
|
||||
|
||||
const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024;
|
||||
|
||||
@@ -53,14 +57,15 @@ struct RemoteWorkspacePluginCreateResponse {
|
||||
pub async fn save_remote_plugin_share(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_path: &Path,
|
||||
codex_home: &Path,
|
||||
plugin_path: &AbsolutePathBuf,
|
||||
remote_plugin_id: Option<&str>,
|
||||
) -> Result<RemotePluginShareSaveResult, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let plugin_path = plugin_path.to_path_buf();
|
||||
let plugin_path_for_archive = plugin_path.as_path().to_path_buf();
|
||||
let (filename, archive_bytes) = tokio::task::spawn_blocking(move || {
|
||||
let filename = archive_filename(&plugin_path)?;
|
||||
let archive_bytes = archive_plugin_for_upload(&plugin_path)?;
|
||||
let filename = archive_filename(&plugin_path_for_archive)?;
|
||||
let archive_bytes = archive_plugin_for_upload(&plugin_path_for_archive)?;
|
||||
Ok::<_, RemotePluginCatalogError>((filename, archive_bytes))
|
||||
})
|
||||
.await
|
||||
@@ -93,6 +98,17 @@ pub async fn save_remote_plugin_share(
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = local_paths::record_plugin_share_local_path(
|
||||
codex_home,
|
||||
&response.plugin_id,
|
||||
plugin_path.clone(),
|
||||
) {
|
||||
warn!(
|
||||
remote_plugin_id = %response.plugin_id,
|
||||
"failed to record plugin share local path mapping: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(RemotePluginShareSaveResult {
|
||||
remote_plugin_id: response.plugin_id,
|
||||
share_url: response.share_url,
|
||||
@@ -102,7 +118,8 @@ pub async fn save_remote_plugin_share(
|
||||
pub async fn list_remote_plugin_shares(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<Vec<RemotePluginSummary>, RemotePluginCatalogError> {
|
||||
codex_home: &Path,
|
||||
) -> Result<Vec<RemotePluginShareSummary>, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let created_plugins = fetch_created_workspace_plugins(config, auth).await?;
|
||||
if created_plugins.is_empty() {
|
||||
@@ -115,16 +132,30 @@ pub async fn list_remote_plugin_shares(
|
||||
.into_iter()
|
||||
.map(|plugin| (plugin.plugin.id.clone(), plugin))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let local_plugin_paths =
|
||||
local_paths::load_plugin_share_local_paths(codex_home).unwrap_or_else(|err| {
|
||||
warn!("failed to load plugin share local path mapping: {err}");
|
||||
BTreeMap::new()
|
||||
});
|
||||
|
||||
Ok(created_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id)))
|
||||
.map(|plugin| {
|
||||
let summary = build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id));
|
||||
let local_plugin_path = local_plugin_paths.get(&plugin.id).cloned();
|
||||
RemotePluginShareSummary {
|
||||
summary,
|
||||
share_url: plugin.share_url,
|
||||
local_plugin_path,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn delete_remote_plugin_share(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
) -> Result<(), RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
@@ -132,7 +163,14 @@ pub async fn delete_remote_plugin_share(
|
||||
let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}");
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.delete(&url), auth)?;
|
||||
send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await
|
||||
send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await?;
|
||||
if let Err(err) = local_paths::remove_plugin_share_local_path(codex_home, remote_plugin_id) {
|
||||
warn!(
|
||||
remote_plugin_id = %remote_plugin_id,
|
||||
"failed to remove plugin share local path mapping: {err}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_created_workspace_plugins(
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const PLUGIN_SHARE_LOCAL_PATHS_FILE: &str = ".tmp/plugin-share-local-paths-v1.json";
|
||||
static PLUGIN_SHARE_LOCAL_PATHS_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PluginShareLocalPaths {
|
||||
#[serde(default)]
|
||||
local_plugin_paths_by_remote_plugin_id: BTreeMap<String, AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
pub(crate) fn load_plugin_share_local_paths(
|
||||
codex_home: &Path,
|
||||
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
|
||||
let _guard = lock_plugin_share_local_paths()?;
|
||||
read_plugin_share_local_paths(codex_home)
|
||||
}
|
||||
|
||||
pub(crate) fn record_plugin_share_local_path(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
plugin_path: AbsolutePathBuf,
|
||||
) -> io::Result<()> {
|
||||
let _guard = lock_plugin_share_local_paths()?;
|
||||
let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?;
|
||||
mapping.insert(remote_plugin_id.to_string(), plugin_path);
|
||||
write_plugin_share_local_paths(codex_home, mapping)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_plugin_share_local_path(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
) -> io::Result<()> {
|
||||
let _guard = lock_plugin_share_local_paths()?;
|
||||
let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?;
|
||||
mapping.remove(remote_plugin_id);
|
||||
write_plugin_share_local_paths(codex_home, mapping)
|
||||
}
|
||||
|
||||
fn lock_plugin_share_local_paths() -> io::Result<std::sync::MutexGuard<'static, ()>> {
|
||||
PLUGIN_SHARE_LOCAL_PATHS_LOCK
|
||||
.lock()
|
||||
.map_err(|err| io::Error::other(format!("plugin share local path lock poisoned: {err}")))
|
||||
}
|
||||
|
||||
fn read_plugin_share_local_paths(
|
||||
codex_home: &Path,
|
||||
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
|
||||
let path = plugin_share_local_paths_path(codex_home);
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let mapping = serde_json::from_str::<PluginShareLocalPaths>(&contents).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"failed to parse plugin share local path mapping {}: {err}",
|
||||
path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok(mapping.local_plugin_paths_by_remote_plugin_id)
|
||||
}
|
||||
|
||||
fn read_plugin_share_local_paths_for_update(
|
||||
codex_home: &Path,
|
||||
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
|
||||
match read_plugin_share_local_paths(codex_home) {
|
||||
Ok(mapping) => Ok(mapping),
|
||||
// This is a best-effort cache under .tmp, so malformed state should not
|
||||
// permanently block future share saves or deletes.
|
||||
Err(err) if err.kind() == io::ErrorKind::InvalidData => Ok(BTreeMap::new()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_plugin_share_local_paths(
|
||||
codex_home: &Path,
|
||||
mapping: BTreeMap<String, AbsolutePathBuf>,
|
||||
) -> io::Result<()> {
|
||||
let path = plugin_share_local_paths_path(codex_home);
|
||||
if mapping.is_empty() {
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
let contents = serde_json::to_string_pretty(&PluginShareLocalPaths {
|
||||
local_plugin_paths_by_remote_plugin_id: mapping,
|
||||
})
|
||||
.map_err(io::Error::other)?;
|
||||
write_atomically(&path, &format!("{contents}\n"))
|
||||
}
|
||||
|
||||
fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> {
|
||||
let parent = write_path.parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("path {} has no parent directory", write_path.display()),
|
||||
)
|
||||
})?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
|
||||
tmp.write_all(contents.as_bytes())?;
|
||||
tmp.persist(write_path).map_err(|err| err.error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plugin_share_local_paths_path(codex_home: &Path) -> std::path::PathBuf {
|
||||
codex_home.join(PLUGIN_SHARE_LOCAL_PATHS_FILE)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInterface;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -49,6 +50,25 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> PathBuf {
|
||||
plugin_path
|
||||
}
|
||||
|
||||
fn write_plugin_share_local_path_mapping(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
plugin_path: &AbsolutePathBuf,
|
||||
) {
|
||||
write_file(
|
||||
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
||||
&format!(
|
||||
"{}\n",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"localPluginPathsByRemotePluginId": {
|
||||
remote_plugin_id: plugin_path,
|
||||
},
|
||||
}))
|
||||
.unwrap()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn archive_file_entries(archive_bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let decoder = flate2::read::GzDecoder::new(archive_bytes);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
@@ -87,6 +107,18 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn remote_plugin_json_with_share_url(
|
||||
plugin_id: &str,
|
||||
share_url: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let mut plugin = remote_plugin_json(plugin_id);
|
||||
let serde_json::Value::Object(fields) = &mut plugin else {
|
||||
unreachable!("plugin json should be an object");
|
||||
};
|
||||
fields.insert("share_url".to_string(), json!(share_url));
|
||||
plugin
|
||||
}
|
||||
|
||||
fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value {
|
||||
let mut plugin = remote_plugin_json(plugin_id);
|
||||
let serde_json::Value::Object(fields) = &mut plugin else {
|
||||
@@ -127,9 +159,13 @@ fn expected_plugin_interface() -> PluginInterface {
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_remote_plugin_share_creates_workspace_plugin() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
|
||||
let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len();
|
||||
let plugin_path =
|
||||
AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap();
|
||||
let archive_size = archive_plugin_for_upload(plugin_path.as_path())
|
||||
.unwrap()
|
||||
.len();
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -178,6 +214,7 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
|
||||
let result = save_remote_plugin_share(
|
||||
&config,
|
||||
Some(&auth),
|
||||
codex_home.path(),
|
||||
&plugin_path,
|
||||
/*remote_plugin_id*/ None,
|
||||
)
|
||||
@@ -191,6 +228,10 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
|
||||
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(),
|
||||
BTreeMap::from([("plugins_123".to_string(), plugin_path)])
|
||||
);
|
||||
|
||||
let requests = server.received_requests().await.unwrap_or_default();
|
||||
let upload_request = requests
|
||||
@@ -261,9 +302,13 @@ fn archive_plugin_for_upload_places_manifest_at_archive_root() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
|
||||
let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len();
|
||||
let plugin_path =
|
||||
AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap();
|
||||
let archive_size = archive_plugin_for_upload(plugin_path.as_path())
|
||||
.unwrap()
|
||||
.len();
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -303,9 +348,15 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = save_remote_plugin_share(&config, Some(&auth), &plugin_path, Some("plugins_123"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = save_remote_plugin_share(
|
||||
&config,
|
||||
Some(&auth),
|
||||
codex_home.path(),
|
||||
&plugin_path,
|
||||
Some("plugins_123"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
@@ -318,6 +369,10 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let local_plugin_path =
|
||||
AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap();
|
||||
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path);
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -332,7 +387,10 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
))
|
||||
.and(query_param_is_missing("pageToken"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_123")],
|
||||
"plugins": [remote_plugin_json_with_share_url(
|
||||
"plugins_123",
|
||||
Some("https://chatgpt.example/plugins/share/share-key-1"),
|
||||
)],
|
||||
"pagination": {
|
||||
"next_page_token": "page-2"
|
||||
},
|
||||
@@ -350,7 +408,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
))
|
||||
.and(query_param("pageToken", "page-2"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_456")],
|
||||
"plugins": [remote_plugin_json_with_share_url("plugins_456", /*share_url*/ None)],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
@@ -367,32 +425,40 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = list_remote_plugin_shares(&config, Some(&auth))
|
||||
let result = list_remote_plugin_shares(&config, Some(&auth), codex_home.path())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
RemotePluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
RemotePluginShareSummary {
|
||||
summary: RemotePluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
|
||||
local_plugin_path: Some(local_plugin_path),
|
||||
},
|
||||
RemotePluginSummary {
|
||||
id: "plugins_456".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
RemotePluginShareSummary {
|
||||
summary: RemotePluginSummary {
|
||||
id: "plugins_456".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: None,
|
||||
local_plugin_path: None,
|
||||
}
|
||||
]
|
||||
);
|
||||
@@ -400,6 +466,10 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_remote_plugin_share_deletes_workspace_plugin() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let local_plugin_path =
|
||||
AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap();
|
||||
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path);
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -413,7 +483,11 @@ async fn delete_remote_plugin_share_deletes_workspace_plugin() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
delete_remote_plugin_share(&config, Some(&auth), "plugins_123")
|
||||
delete_remote_plugin_share(&config, Some(&auth), codex_home.path(), "plugins_123")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(),
|
||||
BTreeMap::new()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -693,6 +693,45 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"DebugConfigLockToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"allow_codex_version_mismatch": {
|
||||
"description": "Allow replaying a lock generated by a different Codex version.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"export_dir": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Directory where Codex writes effective session config lock files."
|
||||
},
|
||||
"load_path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Lockfile to replay as the authoritative effective config."
|
||||
},
|
||||
"save_fields_resolved_from_model_catalog": {
|
||||
"description": "Save fields resolved from the model catalog/session configuration.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"DebugToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"config_lockfile": {
|
||||
"$ref": "#/definitions/DebugConfigLockToml"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ExternalConfigMigrationPrompts": {
|
||||
"additionalProperties": false,
|
||||
"description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.",
|
||||
@@ -853,6 +892,7 @@
|
||||
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`.",
|
||||
"properties": {
|
||||
"max_bytes": {
|
||||
"default": null,
|
||||
"description": "If set, the maximum size of the history file in bytes. The oldest entries are dropped once the file exceeds this limit.",
|
||||
"format": "uint",
|
||||
"minimum": 0.0,
|
||||
@@ -864,12 +904,10 @@
|
||||
"$ref": "#/definitions/HistoryPersistence"
|
||||
}
|
||||
],
|
||||
"default": "save-all",
|
||||
"description": "If true, history entries will not be written to disk."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"persistence"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HistoryPersistence": {
|
||||
@@ -2528,6 +2566,11 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"status_line_use_colors": {
|
||||
"default": true,
|
||||
"description": "Color status line items with colors derived from the active syntax theme. Defaults to `true`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"terminal_resize_reflow_max_rows": {
|
||||
"default": null,
|
||||
"description": "Trim terminal resize-reflow replay to the most recent rendered terminal rows when the transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to keep all rendered rows.",
|
||||
@@ -3624,6 +3667,7 @@
|
||||
"description": "Agent-related settings (thread limits, etc.)."
|
||||
},
|
||||
"allow_login_shell": {
|
||||
"default": true,
|
||||
"description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.",
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -3709,6 +3753,14 @@
|
||||
"description": "Compact prompt used for history compaction.",
|
||||
"type": "string"
|
||||
},
|
||||
"debug": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DebugToml"
|
||||
}
|
||||
],
|
||||
"description": "Debugging and reproducibility settings."
|
||||
},
|
||||
"default_permissions": {
|
||||
"description": "Default permissions profile to apply. Names starting with `:` refer to built-in profiles; other names are resolved from the `[permissions]` table.",
|
||||
"type": "string"
|
||||
@@ -4055,6 +4107,7 @@
|
||||
"description": "Compatibility-only settings retained so legacy `ghost_snapshot` config still loads."
|
||||
},
|
||||
"hide_agent_reasoning": {
|
||||
"default": false,
|
||||
"description": "When set to `true`, `AgentReasoning` events will be hidden from the UI/output. Defaults to `false`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -4064,7 +4117,10 @@
|
||||
"$ref": "#/definitions/History"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"default": {
|
||||
"max_bytes": null,
|
||||
"persistence": "save-all"
|
||||
},
|
||||
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`."
|
||||
},
|
||||
"hooks": {
|
||||
@@ -4212,7 +4268,7 @@
|
||||
},
|
||||
"notify": {
|
||||
"default": null,
|
||||
"description": "Optional external command to spawn for end-user notifications.",
|
||||
"description": "Deprecated optional external command to spawn for end-user notifications.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4275,6 +4331,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"project_doc_fallback_filenames": {
|
||||
"default": [],
|
||||
"description": "Ordered list of fallback filenames to look for when AGENTS.md is missing.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
@@ -4282,6 +4339,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"project_doc_max_bytes": {
|
||||
"default": 32768,
|
||||
"description": "Maximum number of bytes to include from an AGENTS.md project doc file.",
|
||||
"format": "uint",
|
||||
"minimum": 0.0,
|
||||
|
||||
@@ -5,16 +5,12 @@ use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::resolve_role_config;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::find_archived_thread_path_by_id_str;
|
||||
use crate::find_thread_path_by_id_str;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::session::emit_subagent_session_started;
|
||||
use crate::session_prefix::format_subagent_context_line;
|
||||
use crate::session_prefix::format_subagent_notification_message;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::thread_manager::ResumeThreadFromRolloutOptions;
|
||||
use crate::thread_manager::ResumeThreadWithHistoryOptions;
|
||||
use crate::thread_manager::ThreadManagerState;
|
||||
use crate::thread_manager::thread_store_from_config;
|
||||
use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
@@ -27,6 +23,7 @@ use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ResumedHistory;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
@@ -34,6 +31,7 @@ use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_rollout::state_db;
|
||||
use codex_state::DirectionalThreadSpawnEdgeStatus;
|
||||
use codex_thread_store::ReadThreadParams;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
@@ -235,7 +233,6 @@ impl AgentControl {
|
||||
state
|
||||
.spawn_new_thread_with_source(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
self.clone(),
|
||||
session_source,
|
||||
/*persist_extended_history*/ false,
|
||||
@@ -246,15 +243,7 @@ impl AgentControl {
|
||||
)
|
||||
.await?
|
||||
}
|
||||
(None, _) => {
|
||||
state
|
||||
.spawn_new_thread(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
self.clone(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
(None, _) => state.spawn_new_thread(config.clone(), self.clone()).await?,
|
||||
};
|
||||
agent_metadata.agent_id = Some(new_thread.thread_id);
|
||||
reservation.commit(agent_metadata.clone());
|
||||
@@ -377,23 +366,21 @@ impl AgentControl {
|
||||
parent_thread.codex.session.flush_rollout().await?;
|
||||
}
|
||||
|
||||
let rollout_path = parent_thread
|
||||
.as_ref()
|
||||
.and_then(|parent_thread| parent_thread.rollout_path())
|
||||
.or(find_thread_path_by_id_str(
|
||||
config.codex_home.as_path(),
|
||||
&parent_thread_id.to_string(),
|
||||
)
|
||||
.await?)
|
||||
let parent_history = state
|
||||
.read_stored_thread(ReadThreadParams {
|
||||
thread_id: parent_thread_id,
|
||||
include_archived: true,
|
||||
include_history: true,
|
||||
})
|
||||
.await?
|
||||
.history
|
||||
.ok_or_else(|| {
|
||||
CodexErr::Fatal(format!(
|
||||
"parent thread rollout unavailable for fork: {parent_thread_id}"
|
||||
"parent thread history unavailable for fork: {parent_thread_id}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut forked_rollout_items = RolloutRecorder::get_rollout_history(&rollout_path)
|
||||
.await?
|
||||
.get_rollout_items();
|
||||
let mut forked_rollout_items = parent_history.items;
|
||||
if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode {
|
||||
forked_rollout_items =
|
||||
truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns);
|
||||
@@ -436,7 +423,6 @@ impl AgentControl {
|
||||
state
|
||||
.fork_thread_with_source(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
InitialHistory::Forked(forked_rollout_items),
|
||||
self.clone(),
|
||||
session_source,
|
||||
@@ -576,24 +562,26 @@ impl AgentControl {
|
||||
let inherited_exec_policy = self
|
||||
.inherited_exec_policy_for_source(&state, Some(&session_source), &config)
|
||||
.await;
|
||||
let rollout_path =
|
||||
match find_thread_path_by_id_str(config.codex_home.as_path(), &thread_id.to_string())
|
||||
.await?
|
||||
{
|
||||
Some(rollout_path) => rollout_path,
|
||||
None => find_archived_thread_path_by_id_str(
|
||||
config.codex_home.as_path(),
|
||||
&thread_id.to_string(),
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?,
|
||||
};
|
||||
let stored_thread = state
|
||||
.read_stored_thread(ReadThreadParams {
|
||||
thread_id,
|
||||
include_archived: true,
|
||||
include_history: true,
|
||||
})
|
||||
.await?;
|
||||
let history = stored_thread
|
||||
.history
|
||||
.ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?
|
||||
.items;
|
||||
|
||||
let resumed_thread = state
|
||||
.resume_thread_from_rollout_with_source(ResumeThreadFromRolloutOptions {
|
||||
.resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions {
|
||||
config: config.clone(),
|
||||
thread_store: thread_store_from_config(&config),
|
||||
rollout_path,
|
||||
initial_history: InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id: thread_id,
|
||||
history,
|
||||
rollout_path: stored_thread.rollout_path,
|
||||
}),
|
||||
agent_control: self.clone(),
|
||||
session_source,
|
||||
inherited_shell_snapshot,
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::SubagentNotification;
|
||||
use crate::thread_manager::thread_store_from_config;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
@@ -27,6 +26,7 @@ use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_thread_store::ArchiveThreadParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::LocalThreadStoreConfig;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
@@ -109,7 +109,7 @@ impl AgentControlHarness {
|
||||
async fn start_thread(&self) -> (ThreadId, Arc<CodexThread>) {
|
||||
let new_thread = self
|
||||
.manager
|
||||
.start_thread(self.config.clone(), thread_store_from_config(&self.config))
|
||||
.start_thread(self.config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
(new_thread.thread_id, new_thread.thread)
|
||||
@@ -610,10 +610,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
Some("Child subagent guidance.".to_string());
|
||||
let new_thread = harness
|
||||
.manager
|
||||
.start_thread(
|
||||
parent_config.clone(),
|
||||
thread_store_from_config(&parent_config),
|
||||
)
|
||||
.start_thread(parent_config.clone())
|
||||
.await
|
||||
.expect("start parent thread");
|
||||
let parent_thread_id = new_thread.thread_id;
|
||||
@@ -956,7 +953,7 @@ async fn spawn_agent_respects_max_threads_limit() {
|
||||
let control = manager.agent_control();
|
||||
|
||||
let _ = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
|
||||
@@ -1313,10 +1310,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
|
||||
let _ = tester_config.features.enable(Feature::MultiAgentV2);
|
||||
let tester_thread_id = harness
|
||||
.manager
|
||||
.start_thread(
|
||||
tester_config.clone(),
|
||||
thread_store_from_config(&tester_config),
|
||||
)
|
||||
.start_thread(tester_config.clone())
|
||||
.await
|
||||
.expect("tester thread should start")
|
||||
.thread_id;
|
||||
@@ -1701,7 +1695,7 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() {
|
||||
.shutdown_live_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should succeed");
|
||||
let store = LocalThreadStore::new(codex_rollout::RolloutConfig::from_view(&harness.config));
|
||||
let store = LocalThreadStore::new(LocalThreadStoreConfig::from_config(&harness.config));
|
||||
store
|
||||
.archive_thread(ArchiveThreadParams {
|
||||
thread_id: child_thread_id,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -74,8 +73,7 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies()
|
||||
.record_into_history(
|
||||
&[ContextualUserFragment::into(
|
||||
crate::context::EnvironmentContext::new(
|
||||
Some(PathBuf::from("/tmp")),
|
||||
"zsh".to_string(),
|
||||
Vec::new(),
|
||||
/*current_date*/ None,
|
||||
/*timezone*/ None,
|
||||
/*network*/ None,
|
||||
|
||||
@@ -30,6 +30,7 @@ use tokio::time::timeout;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::environment_selection::ResolvedTurnEnvironments;
|
||||
use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::new_guardian_review_id;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
@@ -47,7 +48,6 @@ use crate::session::SUBMISSION_CHANNEL_CAPACITY;
|
||||
use crate::session::emit_subagent_session_started;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::session::turn_context::TurnEnvironment;
|
||||
use codex_login::AuthManager;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_protocol::error::CodexErr;
|
||||
@@ -94,11 +94,9 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)),
|
||||
parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
|
||||
parent_trace: None,
|
||||
environments: parent_ctx
|
||||
.environments
|
||||
.iter()
|
||||
.map(TurnEnvironment::selection)
|
||||
.collect(),
|
||||
environment_selections: ResolvedTurnEnvironments {
|
||||
turn_environments: parent_ctx.environments.clone(),
|
||||
},
|
||||
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
|
||||
thread_store: Arc::clone(&parent_session.services.thread_store),
|
||||
}))
|
||||
|
||||
@@ -25,6 +25,7 @@ use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
@@ -93,6 +94,7 @@ pub struct CodexThreadTurnContextOverrides {
|
||||
pub struct CodexThread {
|
||||
pub(crate) codex: Codex,
|
||||
pub(crate) session_source: SessionSource,
|
||||
session_configured: SessionConfiguredEvent,
|
||||
rollout_path: Option<PathBuf>,
|
||||
out_of_band_elicitation_count: Mutex<u64>,
|
||||
_watch_registration: WatchRegistration,
|
||||
@@ -103,6 +105,7 @@ pub struct CodexThread {
|
||||
impl CodexThread {
|
||||
pub(crate) fn new(
|
||||
codex: Codex,
|
||||
session_configured: SessionConfiguredEvent,
|
||||
rollout_path: Option<PathBuf>,
|
||||
session_source: SessionSource,
|
||||
watch_registration: WatchRegistration,
|
||||
@@ -110,6 +113,7 @@ impl CodexThread {
|
||||
Self {
|
||||
codex,
|
||||
session_source,
|
||||
session_configured,
|
||||
rollout_path,
|
||||
out_of_band_elicitation_count: Mutex::new(0),
|
||||
_watch_registration: watch_registration,
|
||||
@@ -377,6 +381,14 @@ impl CodexThread {
|
||||
self.rollout_path.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn session_configured(&self) -> SessionConfiguredEvent {
|
||||
self.session_configured.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn is_running(&self) -> bool {
|
||||
!self.codex.tx_sub.is_closed()
|
||||
}
|
||||
|
||||
pub async fn guardian_trunk_rollout_path(&self) -> Option<PathBuf> {
|
||||
self.codex
|
||||
.session
|
||||
|
||||
@@ -1430,6 +1430,30 @@ async fn cli_override_model_instructions_file_sets_base_instructions() -> std::i
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_instructions_set_base_instructions() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"instructions = "snapshot instructions""#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.base_instructions.as_deref(),
|
||||
Some("snapshot instructions")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
|
||||
@@ -554,6 +554,7 @@ fn config_toml_deserializes_model_availability_nux() {
|
||||
vim_mode_default: false,
|
||||
alternate_screen: AltScreenMode::default(),
|
||||
status_line: None,
|
||||
status_line_use_colors: true,
|
||||
terminal_title: None,
|
||||
theme: None,
|
||||
keymap: TuiKeymap::default(),
|
||||
@@ -568,6 +569,37 @@ fn config_toml_deserializes_model_availability_nux() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_status_line_use_colors_defaults_to_enabled() {
|
||||
let toml = r#"
|
||||
[tui]
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
|
||||
|
||||
assert!(
|
||||
cfg.tui
|
||||
.expect("tui config should deserialize")
|
||||
.status_line_use_colors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_deserializes_status_line_use_colors_disabled() {
|
||||
let toml = r#"
|
||||
[tui]
|
||||
status_line_use_colors = false
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
|
||||
|
||||
assert!(
|
||||
!cfg.tui
|
||||
.expect("tui config should deserialize")
|
||||
.status_line_use_colors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_deserializes_terminal_resize_reflow_config() {
|
||||
let toml = r#"
|
||||
@@ -2097,6 +2129,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
|
||||
vim_mode_default: false,
|
||||
alternate_screen: AltScreenMode::Auto,
|
||||
status_line: None,
|
||||
status_line_use_colors: true,
|
||||
terminal_title: None,
|
||||
theme: None,
|
||||
keymap: TuiKeymap::default(),
|
||||
@@ -6371,6 +6404,10 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home().to_path_buf(),
|
||||
log_dir: fixture.codex_home().join("log").to_path_buf(),
|
||||
config_lock_export_dir: None,
|
||||
config_lock_allow_codex_version_mismatch: false,
|
||||
config_lock_save_fields_resolved_from_model_catalog: true,
|
||||
config_lock_toml: None,
|
||||
config_layer_stack: Default::default(),
|
||||
startup_warnings: Vec::new(),
|
||||
history: History::default(),
|
||||
@@ -6438,6 +6475,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
tool_suggest: ToolSuggestConfig::default(),
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_status_line_use_colors: true,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
@@ -6568,6 +6606,10 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home().to_path_buf(),
|
||||
log_dir: fixture.codex_home().join("log").to_path_buf(),
|
||||
config_lock_export_dir: None,
|
||||
config_lock_allow_codex_version_mismatch: false,
|
||||
config_lock_save_fields_resolved_from_model_catalog: true,
|
||||
config_lock_toml: None,
|
||||
config_layer_stack: Default::default(),
|
||||
startup_warnings: Vec::new(),
|
||||
history: History::default(),
|
||||
@@ -6635,6 +6677,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
tool_suggest: ToolSuggestConfig::default(),
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_status_line_use_colors: true,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
@@ -6719,6 +6762,10 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home().to_path_buf(),
|
||||
log_dir: fixture.codex_home().join("log").to_path_buf(),
|
||||
config_lock_export_dir: None,
|
||||
config_lock_allow_codex_version_mismatch: false,
|
||||
config_lock_save_fields_resolved_from_model_catalog: true,
|
||||
config_lock_toml: None,
|
||||
config_layer_stack: Default::default(),
|
||||
startup_warnings: Vec::new(),
|
||||
history: History::default(),
|
||||
@@ -6786,6 +6833,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
tool_suggest: ToolSuggestConfig::default(),
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_status_line_use_colors: true,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
@@ -6855,6 +6903,10 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home().to_path_buf(),
|
||||
log_dir: fixture.codex_home().join("log").to_path_buf(),
|
||||
config_lock_export_dir: None,
|
||||
config_lock_allow_codex_version_mismatch: false,
|
||||
config_lock_save_fields_resolved_from_model_catalog: true,
|
||||
config_lock_toml: None,
|
||||
config_layer_stack: Default::default(),
|
||||
startup_warnings: Vec::new(),
|
||||
history: History::default(),
|
||||
@@ -6922,6 +6974,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
tool_suggest: ToolSuggestConfig::default(),
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_status_line_use_colors: true,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
@@ -7984,6 +8037,77 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn debug_config_lockfile_export_settings_load_from_nested_table() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[debug.config_lockfile]
|
||||
export_dir = "locks"
|
||||
allow_codex_version_mismatch = true
|
||||
save_fields_resolved_from_model_catalog = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.config_lock_export_dir,
|
||||
Some(AbsolutePathBuf::resolve_path_against_base(
|
||||
"locks",
|
||||
codex_home.path()
|
||||
))
|
||||
);
|
||||
assert!(config.config_lock_allow_codex_version_mismatch);
|
||||
assert!(!config.config_lock_save_fields_resolved_from_model_catalog);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn debug_config_lockfile_load_path_loads_lock_from_nested_table() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let lock_path = codex_home.path().join("session.config.lock.toml");
|
||||
std::fs::write(
|
||||
&lock_path,
|
||||
format!(
|
||||
r#"version = {}
|
||||
codex_version = "older-version"
|
||||
|
||||
[config]
|
||||
"#,
|
||||
crate::config_lock::CONFIG_LOCK_VERSION
|
||||
),
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
format!(
|
||||
r#"[debug.config_lockfile]
|
||||
load_path = '{}'
|
||||
allow_codex_version_mismatch = true
|
||||
save_fields_resolved_from_model_catalog = false
|
||||
"#,
|
||||
lock_path.display()
|
||||
),
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.config_lock_toml.is_some());
|
||||
assert!(config.config_lock_allow_codex_version_mismatch);
|
||||
assert!(!config.config_lock_save_fields_resolved_from_model_catalog);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -104,6 +104,14 @@ pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a config edit that sets `[tui].status_line_use_colors`.
|
||||
pub fn status_line_use_colors_edit(enabled: bool) -> ConfigEdit {
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec!["tui".to_string(), "status_line_use_colors".to_string()],
|
||||
value: value(enabled),
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list.
|
||||
///
|
||||
/// The array is written even when it is empty so "disabled title updates" stays
|
||||
|
||||
@@ -23,7 +23,9 @@ use codex_config::ResidencyRequirement;
|
||||
use codex_config::SandboxModeRequirement;
|
||||
use codex_config::Sourced;
|
||||
use codex_config::ThreadConfigLoader;
|
||||
use codex_config::config_toml::ConfigLockfileToml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::config_toml::DEFAULT_PROJECT_DOC_MAX_BYTES;
|
||||
use codex_config::config_toml::ProjectConfig;
|
||||
use codex_config::config_toml::RealtimeAudioConfig;
|
||||
use codex_config::config_toml::RealtimeConfig;
|
||||
@@ -100,6 +102,7 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -115,6 +118,9 @@ use crate::config::permissions::default_builtin_permission_profile_name;
|
||||
use crate::config::permissions::get_readable_roots_required_for_codex_runtime;
|
||||
use crate::config::permissions::network_proxy_config_for_profile_selection;
|
||||
use crate::config::permissions::validate_user_permission_profile_names;
|
||||
use crate::config_lock::config_without_lock_controls;
|
||||
use crate::config_lock::lock_layer_from_config;
|
||||
use crate::config_lock::read_config_lock_from_path;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use toml::Value as TomlValue;
|
||||
use toml_edit::DocumentMut;
|
||||
@@ -162,7 +168,7 @@ impl Default for GhostSnapshotConfig {
|
||||
/// Maximum number of bytes of the documentation that will be embedded. Larger
|
||||
/// files are *silently truncated* to this size so we do not take up too much of
|
||||
/// the context window.
|
||||
pub(crate) const AGENTS_MD_MAX_BYTES: usize = 32 * 1024; // 32 KiB
|
||||
pub(crate) const AGENTS_MD_MAX_BYTES: usize = DEFAULT_PROJECT_DOC_MAX_BYTES; // 32 KiB
|
||||
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
|
||||
pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION: usize = 4;
|
||||
pub(crate) const DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
|
||||
@@ -473,7 +479,7 @@ pub struct Config {
|
||||
/// - `Some("...")`: use the provided attribution text verbatim
|
||||
pub commit_attribution: Option<String>,
|
||||
|
||||
/// Optional external notifier command. When set, Codex will spawn this
|
||||
/// Deprecated optional external notifier command. When set, Codex will spawn this
|
||||
/// program after each completed *turn* (i.e. when the agent finishes
|
||||
/// processing a user submission). The value must be the full command
|
||||
/// broken into argv tokens **without** the trailing JSON argument - Codex
|
||||
@@ -492,7 +498,7 @@ pub struct Config {
|
||||
/// notify-send Codex '{"type":"agent-turn-complete","turn-id":"12345"}'
|
||||
/// ```
|
||||
///
|
||||
/// If unset the feature is disabled.
|
||||
/// If unset the feature is disabled. Use lifecycle hooks for new automation.
|
||||
pub notify: Option<Vec<String>>,
|
||||
|
||||
/// TUI notification settings, including enabled events, delivery method, and focus condition.
|
||||
@@ -524,6 +530,9 @@ pub struct Config {
|
||||
/// When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`.
|
||||
pub tui_status_line: Option<Vec<String>>,
|
||||
|
||||
/// Whether to color status line items with colors from the active syntax theme.
|
||||
pub tui_status_line_use_colors: bool,
|
||||
|
||||
/// Ordered list of terminal title item identifiers for the TUI.
|
||||
///
|
||||
/// When unset, the TUI defaults to: `activity` and `project`.
|
||||
@@ -620,6 +629,20 @@ pub struct Config {
|
||||
/// Directory where Codex writes log files (defaults to `$CODEX_HOME/log`).
|
||||
pub log_dir: PathBuf,
|
||||
|
||||
/// Directory where Codex writes effective session config lock files.
|
||||
pub config_lock_export_dir: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Whether config lock replay ignores Codex version drift between the
|
||||
/// lock metadata and the regenerated lock.
|
||||
pub config_lock_allow_codex_version_mismatch: bool,
|
||||
|
||||
/// Whether config lock creation saves values resolved from the model
|
||||
/// catalog/session configuration.
|
||||
pub config_lock_save_fields_resolved_from_model_catalog: bool,
|
||||
|
||||
/// Effective config lock used for strict replay validation.
|
||||
pub config_lock_toml: Option<Arc<ConfigLockfileToml>>,
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
pub history: History,
|
||||
|
||||
@@ -789,7 +812,7 @@ pub struct Config {
|
||||
pub otel: codex_config::types::OtelConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct MultiAgentV2Config {
|
||||
pub max_concurrent_threads_per_session: usize,
|
||||
pub min_wait_timeout_ms: i64,
|
||||
@@ -900,6 +923,11 @@ impl ConfigBuilder {
|
||||
}
|
||||
|
||||
pub async fn build(self) -> std::io::Result<Config> {
|
||||
// Keep the large config-loading future off small runtime thread stacks.
|
||||
Box::pin(self.build_inner()).await
|
||||
}
|
||||
|
||||
async fn build_inner(self) -> std::io::Result<Config> {
|
||||
let Self {
|
||||
codex_home,
|
||||
cli_overrides,
|
||||
@@ -958,6 +986,42 @@ impl ConfigBuilder {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, err));
|
||||
}
|
||||
};
|
||||
let config_lock_settings = config_toml
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref());
|
||||
if let Some(config_lock_load_path) =
|
||||
config_lock_settings.and_then(|config_lock| config_lock.load_path.as_ref())
|
||||
{
|
||||
let allow_codex_version_mismatch = config_lock_settings
|
||||
.and_then(|config_lock| config_lock.allow_codex_version_mismatch)
|
||||
.unwrap_or(false);
|
||||
let save_fields_resolved_from_model_catalog = config_lock_settings
|
||||
.and_then(|config_lock| config_lock.save_fields_resolved_from_model_catalog)
|
||||
.unwrap_or(true);
|
||||
let lockfile_toml = read_config_lock_from_path(config_lock_load_path).await?;
|
||||
let expected_lock_config = lockfile_toml.clone();
|
||||
let lock_layer = lock_layer_from_config(config_lock_load_path, &lockfile_toml)?;
|
||||
let lock_config_toml = config_without_lock_controls(&lockfile_toml.config);
|
||||
let lock_config_layer_stack = ConfigLayerStack::new(
|
||||
vec![lock_layer],
|
||||
config_layer_stack.requirements().clone(),
|
||||
config_layer_stack.requirements_toml().clone(),
|
||||
)?;
|
||||
let mut config = Config::load_config_with_layer_stack(
|
||||
LOCAL_FS.as_ref(),
|
||||
lock_config_toml,
|
||||
harness_overrides,
|
||||
codex_home,
|
||||
lock_config_layer_stack,
|
||||
)
|
||||
.await?;
|
||||
config.config_lock_toml = Some(Arc::new(expected_lock_config));
|
||||
config.config_lock_allow_codex_version_mismatch = allow_codex_version_mismatch;
|
||||
config.config_lock_save_fields_resolved_from_model_catalog =
|
||||
save_fields_resolved_from_model_catalog;
|
||||
return Ok(config);
|
||||
}
|
||||
Config::load_config_with_layer_stack(
|
||||
LOCAL_FS.as_ref(),
|
||||
config_toml,
|
||||
@@ -2627,7 +2691,9 @@ impl Config {
|
||||
"model instructions file",
|
||||
)
|
||||
.await?;
|
||||
let base_instructions = base_instructions.or(file_base_instructions);
|
||||
let base_instructions = base_instructions
|
||||
.or(file_base_instructions)
|
||||
.or(cfg.instructions.clone());
|
||||
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
|
||||
let include_permissions_instructions = config_profile
|
||||
.include_permissions_instructions
|
||||
@@ -2899,6 +2965,24 @@ impl Config {
|
||||
codex_home,
|
||||
sqlite_home,
|
||||
log_dir,
|
||||
config_lock_export_dir: cfg
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref())
|
||||
.and_then(|config_lock| config_lock.export_dir.clone()),
|
||||
config_lock_allow_codex_version_mismatch: cfg
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref())
|
||||
.and_then(|config_lock| config_lock.allow_codex_version_mismatch)
|
||||
.unwrap_or(false),
|
||||
config_lock_save_fields_resolved_from_model_catalog: cfg
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref())
|
||||
.and_then(|config_lock| config_lock.save_fields_resolved_from_model_catalog)
|
||||
.unwrap_or(true),
|
||||
config_lock_toml: None,
|
||||
config_layer_stack,
|
||||
history,
|
||||
ephemeral: ephemeral.unwrap_or_default(),
|
||||
@@ -3011,6 +3095,11 @@ impl Config {
|
||||
.map(|t| t.alternate_screen)
|
||||
.unwrap_or_default(),
|
||||
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
|
||||
tui_status_line_use_colors: cfg
|
||||
.tui
|
||||
.as_ref()
|
||||
.map(|t| t.status_line_use_colors)
|
||||
.unwrap_or(true),
|
||||
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
|
||||
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
|
||||
terminal_resize_reflow,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::io;
|
||||
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::config_toml::ConfigLockfileToml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use similar::TextDiff;
|
||||
|
||||
pub(crate) const CONFIG_LOCK_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ConfigLockReplayOptions {
|
||||
pub allow_codex_version_mismatch: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn read_config_lock_from_path(
|
||||
path: &AbsolutePathBuf,
|
||||
) -> io::Result<ConfigLockfileToml> {
|
||||
let contents = tokio::fs::read_to_string(path).await.map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to read config lock file {}: {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let lockfile: ConfigLockfileToml = toml::from_str(&contents).map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to parse config lock file {}: {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
validate_config_lock_metadata_shape(&lockfile)?;
|
||||
Ok(lockfile)
|
||||
}
|
||||
|
||||
pub(crate) fn config_lockfile(config: ConfigToml) -> ConfigLockfileToml {
|
||||
ConfigLockfileToml {
|
||||
version: CONFIG_LOCK_VERSION,
|
||||
codex_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_config_lock_replay(
|
||||
expected_lock: &ConfigLockfileToml,
|
||||
actual_lock: &ConfigLockfileToml,
|
||||
options: ConfigLockReplayOptions,
|
||||
) -> io::Result<()> {
|
||||
validate_config_lock_metadata_shape(expected_lock)?;
|
||||
validate_config_lock_metadata_shape(actual_lock)?;
|
||||
|
||||
if !options.allow_codex_version_mismatch
|
||||
&& expected_lock.codex_version != actual_lock.codex_version
|
||||
{
|
||||
return Err(config_lock_error(format!(
|
||||
"config lock Codex version mismatch: lock was generated by {}, current version is {}; set debug.config_lockfile.allow_codex_version_mismatch=true to ignore this",
|
||||
expected_lock.codex_version, actual_lock.codex_version
|
||||
)));
|
||||
}
|
||||
|
||||
let expected_lock = config_lock_for_comparison(expected_lock, options);
|
||||
let actual_lock = config_lock_for_comparison(actual_lock, options);
|
||||
if expected_lock != actual_lock {
|
||||
let diff = compact_diff("config", &expected_lock, &actual_lock)
|
||||
.unwrap_or_else(|err| format!("failed to build config lock diff: {err}"));
|
||||
return Err(config_lock_error(format!(
|
||||
"replayed effective config does not match config lock: {diff}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn lock_layer_from_config(
|
||||
lock_path: &AbsolutePathBuf,
|
||||
lockfile: &ConfigLockfileToml,
|
||||
) -> io::Result<ConfigLayerEntry> {
|
||||
let value = toml_value(
|
||||
&config_without_lock_controls(&lockfile.config),
|
||||
"config lock",
|
||||
)?;
|
||||
Ok(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: lock_path.clone(),
|
||||
},
|
||||
value,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn config_without_lock_controls(config: &ConfigToml) -> ConfigToml {
|
||||
let mut config = config.clone();
|
||||
clear_config_lock_debug_controls(&mut config);
|
||||
config
|
||||
}
|
||||
|
||||
pub(crate) fn clear_config_lock_debug_controls(config: &mut ConfigToml) {
|
||||
if let Some(debug) = config.debug.as_mut() {
|
||||
debug.config_lockfile = None;
|
||||
}
|
||||
if config
|
||||
.debug
|
||||
.as_ref()
|
||||
.is_some_and(|debug| debug.config_lockfile.is_none())
|
||||
{
|
||||
config.debug = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_config_lock_metadata_shape(lock: &ConfigLockfileToml) -> io::Result<()> {
|
||||
if lock.version != CONFIG_LOCK_VERSION {
|
||||
return Err(config_lock_error(format!(
|
||||
"unsupported config lock version {}; expected {CONFIG_LOCK_VERSION}",
|
||||
lock.version
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_lock_for_comparison(
|
||||
lockfile: &ConfigLockfileToml,
|
||||
options: ConfigLockReplayOptions,
|
||||
) -> ConfigLockfileToml {
|
||||
let mut lockfile = lockfile.clone();
|
||||
clear_config_lock_debug_controls(&mut lockfile.config);
|
||||
if options.allow_codex_version_mismatch {
|
||||
lockfile.codex_version.clear();
|
||||
}
|
||||
lockfile
|
||||
}
|
||||
|
||||
fn config_lock_error(message: impl Into<String>) -> io::Error {
|
||||
io::Error::other(message.into())
|
||||
}
|
||||
|
||||
fn compact_diff<T: Serialize>(root: &str, expected: &T, actual: &T) -> io::Result<String> {
|
||||
let expected = toml::to_string_pretty(expected).map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to serialize expected {root} lock TOML: {err}"
|
||||
))
|
||||
})?;
|
||||
let actual = toml::to_string_pretty(actual).map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to serialize actual {root} lock TOML: {err}"
|
||||
))
|
||||
})?;
|
||||
Ok(TextDiff::from_lines(&expected, &actual)
|
||||
.unified_diff()
|
||||
.context_radius(2)
|
||||
.header("expected", "actual")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn toml_value<T: Serialize>(value: &T, label: &str) -> io::Result<toml::Value> {
|
||||
toml::Value::try_from(value)
|
||||
.map_err(|err| config_lock_error(format!("failed to serialize {label}: {err}")))
|
||||
}
|
||||
|
||||
pub(crate) fn toml_round_trip<T>(value: &impl Serialize, label: &'static str) -> io::Result<T>
|
||||
where
|
||||
T: DeserializeOwned + Serialize,
|
||||
{
|
||||
let value = toml_value(value, label)?;
|
||||
let toml = value.clone().try_into().map_err(|err| {
|
||||
config_lock_error(format!("failed to convert {label} to TOML shape: {err}"))
|
||||
})?;
|
||||
let represented_value = toml_value(&toml, label)?;
|
||||
if represented_value != value {
|
||||
return Err(config_lock_error(format!(
|
||||
"resolved {label} cannot be fully represented as TOML"
|
||||
)));
|
||||
}
|
||||
Ok(toml)
|
||||
}
|
||||
@@ -1,21 +1,85 @@
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::shell::Shell;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::TurnContextNetworkItem;
|
||||
use std::path::PathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use super::ContextualUserFragment;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct EnvironmentContext {
|
||||
pub(crate) cwd: Option<PathBuf>,
|
||||
pub(crate) shell: String,
|
||||
pub(crate) environments: EnvironmentContextEnvironments,
|
||||
pub(crate) current_date: Option<String>,
|
||||
pub(crate) timezone: Option<String>,
|
||||
pub(crate) network: Option<NetworkContext>,
|
||||
pub(crate) subagents: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct EnvironmentContextEnvironment {
|
||||
pub(crate) id: String,
|
||||
pub(crate) cwd: AbsolutePathBuf,
|
||||
pub(crate) shell: String,
|
||||
}
|
||||
|
||||
impl EnvironmentContextEnvironment {
|
||||
fn legacy(cwd: AbsolutePathBuf, shell: String) -> Self {
|
||||
Self {
|
||||
id: String::new(),
|
||||
cwd,
|
||||
shell,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_turn_environments(
|
||||
environments: &[crate::session::turn_context::TurnEnvironment],
|
||||
) -> Vec<Self> {
|
||||
environments
|
||||
.iter()
|
||||
.map(|environment| Self {
|
||||
id: environment.environment_id.clone(),
|
||||
cwd: environment.cwd.clone(),
|
||||
shell: environment.shell.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum EnvironmentContextEnvironments {
|
||||
None,
|
||||
Single(EnvironmentContextEnvironment),
|
||||
Multiple(Vec<EnvironmentContextEnvironment>),
|
||||
}
|
||||
|
||||
impl EnvironmentContextEnvironments {
|
||||
fn from_vec(environments: Vec<EnvironmentContextEnvironment>) -> Self {
|
||||
let mut environments = environments;
|
||||
match environments.pop() {
|
||||
None => Self::None,
|
||||
Some(environment) if environments.is_empty() => Self::Single(environment),
|
||||
Some(environment) => {
|
||||
environments.push(environment);
|
||||
Self::Multiple(environments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn equals_except_shell(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::None, Self::None) => true,
|
||||
(Self::Single(left), Self::Single(right)) => left.cwd == right.cwd,
|
||||
(Self::Multiple(left), Self::Multiple(right)) => {
|
||||
left.len() == right.len()
|
||||
&& left
|
||||
.iter()
|
||||
.zip(right.iter())
|
||||
.all(|(left, right)| left.id == right.id && left.cwd == right.cwd)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(crate) struct NetworkContext {
|
||||
allowed_domains: Vec<String>,
|
||||
@@ -33,16 +97,30 @@ impl NetworkContext {
|
||||
|
||||
impl EnvironmentContext {
|
||||
pub(crate) fn new(
|
||||
cwd: Option<PathBuf>,
|
||||
shell: String,
|
||||
environments: Vec<EnvironmentContextEnvironment>,
|
||||
current_date: Option<String>,
|
||||
timezone: Option<String>,
|
||||
network: Option<NetworkContext>,
|
||||
subagents: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
shell,
|
||||
environments: EnvironmentContextEnvironments::from_vec(environments),
|
||||
current_date,
|
||||
timezone,
|
||||
network,
|
||||
subagents,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_with_environments(
|
||||
environments: EnvironmentContextEnvironments,
|
||||
current_date: Option<String>,
|
||||
timezone: Option<String>,
|
||||
network: Option<NetworkContext>,
|
||||
subagents: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
environments,
|
||||
current_date,
|
||||
timezone,
|
||||
network,
|
||||
@@ -54,19 +132,11 @@ impl EnvironmentContext {
|
||||
/// comparing turn to turn, since the initial environment_context will
|
||||
/// include the shell, and then it is not configurable from turn to turn.
|
||||
pub(crate) fn equals_except_shell(&self, other: &EnvironmentContext) -> bool {
|
||||
let EnvironmentContext {
|
||||
cwd,
|
||||
current_date,
|
||||
timezone,
|
||||
network,
|
||||
subagents,
|
||||
shell: _,
|
||||
} = other;
|
||||
self.cwd == *cwd
|
||||
&& self.current_date == *current_date
|
||||
&& self.timezone == *timezone
|
||||
&& self.network == *network
|
||||
&& self.subagents == *subagents
|
||||
self.environments.equals_except_shell(&other.environments)
|
||||
&& self.current_date == other.current_date
|
||||
&& self.timezone == other.timezone
|
||||
&& self.network == other.network
|
||||
&& self.subagents == other.subagents
|
||||
}
|
||||
|
||||
pub(crate) fn diff_from_turn_context_item(
|
||||
@@ -74,18 +144,29 @@ impl EnvironmentContext {
|
||||
after: &EnvironmentContext,
|
||||
) -> Self {
|
||||
let before_network = Self::network_from_turn_context_item(before);
|
||||
let cwd = match &after.cwd {
|
||||
Some(cwd) if before.cwd.as_path() != cwd.as_path() => Some(cwd.clone()),
|
||||
_ => None,
|
||||
let environments = match &after.environments {
|
||||
EnvironmentContextEnvironments::Single(environment) => {
|
||||
if before.cwd.as_path() != environment.cwd.as_path() {
|
||||
EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment::legacy(
|
||||
environment.cwd.clone(),
|
||||
environment.shell.clone(),
|
||||
))
|
||||
} else {
|
||||
EnvironmentContextEnvironments::None
|
||||
}
|
||||
}
|
||||
EnvironmentContextEnvironments::Multiple(environments) => {
|
||||
EnvironmentContextEnvironments::Multiple(environments.clone())
|
||||
}
|
||||
EnvironmentContextEnvironments::None => EnvironmentContextEnvironments::None,
|
||||
};
|
||||
let network = if before_network != after.network {
|
||||
after.network.clone()
|
||||
} else {
|
||||
before_network
|
||||
};
|
||||
EnvironmentContext::new(
|
||||
cwd,
|
||||
after.shell.clone(),
|
||||
EnvironmentContext::new_with_environments(
|
||||
environments,
|
||||
after.current_date.clone(),
|
||||
after.timezone.clone(),
|
||||
network,
|
||||
@@ -93,10 +174,9 @@ impl EnvironmentContext {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
|
||||
pub(crate) fn from_turn_context(turn_context: &TurnContext) -> Self {
|
||||
Self::new(
|
||||
Some(turn_context.cwd.to_path_buf()),
|
||||
shell.name().to_string(),
|
||||
EnvironmentContextEnvironment::from_turn_environments(&turn_context.environments),
|
||||
turn_context.current_date.clone(),
|
||||
turn_context.timezone.clone(),
|
||||
Self::network_from_turn_context(turn_context),
|
||||
@@ -108,9 +188,12 @@ impl EnvironmentContext {
|
||||
turn_context_item: &TurnContextItem,
|
||||
shell: String,
|
||||
) -> Self {
|
||||
let cwd = match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) {
|
||||
Ok(cwd) => cwd,
|
||||
Err(_) => AbsolutePathBuf::resolve_path_against_base(&turn_context_item.cwd, "/"),
|
||||
};
|
||||
Self::new(
|
||||
Some(turn_context_item.cwd.clone()),
|
||||
shell,
|
||||
vec![EnvironmentContextEnvironment::legacy(cwd, shell)],
|
||||
turn_context_item.current_date.clone(),
|
||||
turn_context_item.timezone.clone(),
|
||||
Self::network_from_turn_context_item(turn_context_item),
|
||||
@@ -168,11 +251,29 @@ impl ContextualUserFragment for EnvironmentContext {
|
||||
|
||||
fn body(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(cwd) = &self.cwd {
|
||||
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
|
||||
match &self.environments {
|
||||
EnvironmentContextEnvironments::Single(environment) => {
|
||||
lines.push(format!(
|
||||
" <cwd>{}</cwd>",
|
||||
environment.cwd.to_string_lossy()
|
||||
));
|
||||
lines.push(format!(" <shell>{}</shell>", environment.shell));
|
||||
}
|
||||
EnvironmentContextEnvironments::Multiple(environments) => {
|
||||
lines.push(" <environments>".to_string());
|
||||
for environment in environments {
|
||||
lines.push(format!(" <environment id=\"{}\">", environment.id));
|
||||
lines.push(format!(
|
||||
" <cwd>{}</cwd>",
|
||||
environment.cwd.to_string_lossy()
|
||||
));
|
||||
lines.push(format!(" <shell>{}</shell>", environment.shell));
|
||||
lines.push(" </environment>".to_string());
|
||||
}
|
||||
lines.push(" </environments>".to_string());
|
||||
}
|
||||
EnvironmentContextEnvironments::None => {}
|
||||
}
|
||||
|
||||
lines.push(format!(" <shell>{}</shell>", self.shell));
|
||||
if let Some(current_date) = &self.current_date {
|
||||
lines.push(format!(" <current_date>{current_date}</current_date>"));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::shell::ShellType;
|
||||
|
||||
use super::*;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use core_test_support::test_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
@@ -14,12 +15,19 @@ fn fake_shell_name() -> String {
|
||||
shell.name().to_string()
|
||||
}
|
||||
|
||||
fn test_abs_path(unix_path: &str) -> AbsolutePathBuf {
|
||||
test_path_buf(unix_path).abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_workspace_write_environment_context() {
|
||||
let cwd = test_path_buf("/repo");
|
||||
let context = EnvironmentContext::new(
|
||||
Some(cwd.clone()),
|
||||
fake_shell_name(),
|
||||
vec![EnvironmentContextEnvironment {
|
||||
id: "local".to_string(),
|
||||
cwd: cwd.abs(),
|
||||
shell: fake_shell_name(),
|
||||
}],
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
/*network*/ None,
|
||||
@@ -46,8 +54,11 @@ fn serialize_environment_context_with_network() {
|
||||
vec!["blocked.example.com".to_string()],
|
||||
);
|
||||
let context = EnvironmentContext::new(
|
||||
Some(test_path_buf("/repo")),
|
||||
fake_shell_name(),
|
||||
vec![EnvironmentContextEnvironment {
|
||||
id: "local".to_string(),
|
||||
cwd: test_path_buf("/repo").abs(),
|
||||
shell: fake_shell_name(),
|
||||
}],
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
Some(network),
|
||||
@@ -75,8 +86,7 @@ fn serialize_environment_context_with_network() {
|
||||
#[test]
|
||||
fn serialize_read_only_environment_context() {
|
||||
let context = EnvironmentContext::new(
|
||||
/*cwd*/ None,
|
||||
fake_shell_name(),
|
||||
Vec::new(),
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
/*network*/ None,
|
||||
@@ -84,7 +94,6 @@ fn serialize_read_only_environment_context() {
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<shell>bash</shell>
|
||||
<current_date>2026-02-26</current_date>
|
||||
<timezone>America/Los_Angeles</timezone>
|
||||
</environment_context>"#;
|
||||
@@ -95,16 +104,22 @@ fn serialize_read_only_environment_context() {
|
||||
#[test]
|
||||
fn equals_except_shell_compares_cwd() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
fake_shell_name(),
|
||||
vec![EnvironmentContextEnvironment {
|
||||
id: "local".to_string(),
|
||||
cwd: test_abs_path("/repo"),
|
||||
shell: fake_shell_name(),
|
||||
}],
|
||||
/*current_date*/ None,
|
||||
/*timezone*/ None,
|
||||
/*network*/ None,
|
||||
/*subagents*/ None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
fake_shell_name(),
|
||||
vec![EnvironmentContextEnvironment {
|
||||
id: "local".to_string(),
|
||||
cwd: test_abs_path("/repo"),
|
||||
shell: fake_shell_name(),
|
||||
}],
|
||||
/*current_date*/ None,
|
||||
/*timezone*/ None,
|
||||
/*network*/ None,
|
||||
@@ -116,16 +131,22 @@ fn equals_except_shell_compares_cwd() {
|
||||
#[test]
|
||||
fn equals_except_shell_compares_cwd_differences() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo1")),
|
||||
fake_shell_name(),
|
||||
vec![EnvironmentContextEnvironment {
|
||||
id: "local".to_string(),
|
||||
cwd: test_abs_path("/repo1"),
|
||||
shell: fake_shell_name(),
|
||||
}],
|
||||
/*current_date*/ None,
|
||||
/*timezone*/ None,
|
||||
/*network*/ None,
|
||||
/*subagents*/ None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo2")),
|
||||
fake_shell_name(),
|
||||
vec![EnvironmentContextEnvironment {
|
||||
id: "local".to_string(),
|
||||
cwd: test_abs_path("/repo2"),
|
||||
shell: fake_shell_name(),
|
||||
}],
|
||||
/*current_date*/ None,
|
||||
/*timezone*/ None,
|
||||
/*network*/ None,
|
||||
@@ -138,16 +159,22 @@ fn equals_except_shell_compares_cwd_differences() {
|
||||
#[test]
|
||||
fn equals_except_shell_ignores_shell() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
"bash".to_string(),
|
||||
vec![EnvironmentContextEnvironment {
|
||||
id: "local".to_string(),
|
||||
cwd: test_abs_path("/repo"),
|
||||
shell: "bash".to_string(),
|
||||
}],
|
||||
/*current_date*/ None,
|
||||
/*timezone*/ None,
|
||||
/*network*/ None,
|
||||
/*subagents*/ None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
"zsh".to_string(),
|
||||
vec![EnvironmentContextEnvironment {
|
||||
id: "other".to_string(),
|
||||
cwd: test_abs_path("/repo"),
|
||||
shell: "zsh".to_string(),
|
||||
}],
|
||||
/*current_date*/ None,
|
||||
/*timezone*/ None,
|
||||
/*network*/ None,
|
||||
@@ -160,8 +187,11 @@ fn equals_except_shell_ignores_shell() {
|
||||
#[test]
|
||||
fn serialize_environment_context_with_subagents() {
|
||||
let context = EnvironmentContext::new(
|
||||
Some(test_path_buf("/repo")),
|
||||
fake_shell_name(),
|
||||
vec![EnvironmentContextEnvironment {
|
||||
id: "local".to_string(),
|
||||
cwd: test_path_buf("/repo").abs(),
|
||||
shell: fake_shell_name(),
|
||||
}],
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
/*network*/ None,
|
||||
@@ -184,3 +214,48 @@ fn serialize_environment_context_with_subagents() {
|
||||
|
||||
assert_eq!(context.render(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_environment_context_with_multiple_selected_environments() {
|
||||
let local_cwd = test_path_buf("/repo/local");
|
||||
let remote_cwd = test_path_buf("/repo/remote");
|
||||
let context = EnvironmentContext::new(
|
||||
vec![
|
||||
EnvironmentContextEnvironment {
|
||||
id: "local".to_string(),
|
||||
cwd: local_cwd.abs(),
|
||||
shell: "bash".to_string(),
|
||||
},
|
||||
EnvironmentContextEnvironment {
|
||||
id: "remote".to_string(),
|
||||
cwd: remote_cwd.abs(),
|
||||
shell: "bash".to_string(),
|
||||
},
|
||||
],
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
/*network*/ None,
|
||||
/*subagents*/ None,
|
||||
);
|
||||
|
||||
let expected = format!(
|
||||
r#"<environment_context>
|
||||
<environments>
|
||||
<environment id="local">
|
||||
<cwd>{}</cwd>
|
||||
<shell>bash</shell>
|
||||
</environment>
|
||||
<environment id="remote">
|
||||
<cwd>{}</cwd>
|
||||
<shell>bash</shell>
|
||||
</environment>
|
||||
</environments>
|
||||
<current_date>2026-02-26</current_date>
|
||||
<timezone>America/Los_Angeles</timezone>
|
||||
</environment_context>"#,
|
||||
local_cwd.display(),
|
||||
remote_cwd.display()
|
||||
);
|
||||
|
||||
assert_eq!(context.render(), expected);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ fn build_environment_update_item(
|
||||
|
||||
let prev = previous?;
|
||||
let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string());
|
||||
let next_context = EnvironmentContext::from_turn_context(next, shell);
|
||||
let next_context = EnvironmentContext::from_turn_context(next);
|
||||
if prev_context.equals_except_shell(&next_context) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::session::turn_context::TurnEnvironment;
|
||||
|
||||
pub(crate) fn default_thread_environment_selections(
|
||||
environment_manager: &EnvironmentManager,
|
||||
cwd: &AbsolutePathBuf,
|
||||
@@ -21,42 +24,64 @@ pub(crate) fn default_thread_environment_selections(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn validate_environment_selections(
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ResolvedTurnEnvironments {
|
||||
pub(crate) turn_environments: Vec<TurnEnvironment>,
|
||||
}
|
||||
|
||||
impl ResolvedTurnEnvironments {
|
||||
pub(crate) fn to_selections(&self) -> Vec<TurnEnvironmentSelection> {
|
||||
self.turn_environments
|
||||
.iter()
|
||||
.map(TurnEnvironment::selection)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn primary_turn_environment(&self) -> Option<&TurnEnvironment> {
|
||||
self.turn_environments.first()
|
||||
}
|
||||
|
||||
pub(crate) fn primary_environment(&self) -> Option<Arc<codex_exec_server::Environment>> {
|
||||
self.primary_turn_environment()
|
||||
.map(|environment| Arc::clone(&environment.environment))
|
||||
}
|
||||
|
||||
pub(crate) fn primary_filesystem(&self) -> Option<Arc<dyn ExecutorFileSystem>> {
|
||||
self.primary_turn_environment()
|
||||
.map(|environment| environment.environment.get_filesystem())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_environment_selections(
|
||||
environment_manager: &EnvironmentManager,
|
||||
environments: &[TurnEnvironmentSelection],
|
||||
) -> CodexResult<()> {
|
||||
) -> CodexResult<ResolvedTurnEnvironments> {
|
||||
let mut seen_environment_ids = HashSet::with_capacity(environments.len());
|
||||
let mut turn_environments = Vec::with_capacity(environments.len());
|
||||
for selected_environment in environments {
|
||||
if environment_manager
|
||||
.get_environment(&selected_environment.environment_id)
|
||||
.is_none()
|
||||
{
|
||||
if !seen_environment_ids.insert(selected_environment.environment_id.as_str()) {
|
||||
return Err(CodexErr::InvalidRequest(format!(
|
||||
"unknown turn environment id `{}`",
|
||||
"duplicate turn environment id `{}`",
|
||||
selected_environment.environment_id
|
||||
)));
|
||||
}
|
||||
let environment_id = selected_environment.environment_id.clone();
|
||||
let environment = environment_manager
|
||||
.get_environment(&environment_id)
|
||||
.ok_or_else(|| {
|
||||
CodexErr::InvalidRequest(format!("unknown turn environment id `{environment_id}`"))
|
||||
})?;
|
||||
turn_environments.push(TurnEnvironment {
|
||||
environment_id,
|
||||
environment,
|
||||
cwd: selected_environment.cwd.clone(),
|
||||
// TODO(starr): Resolve shell metadata per environment instead of
|
||||
// hardcoding bash.
|
||||
shell: "bash".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn selected_primary_environment(
|
||||
environment_manager: &EnvironmentManager,
|
||||
environments: &[TurnEnvironmentSelection],
|
||||
) -> CodexResult<Option<Arc<Environment>>> {
|
||||
environments
|
||||
.first()
|
||||
.map(|selected_environment| {
|
||||
environment_manager
|
||||
.get_environment(&selected_environment.environment_id)
|
||||
.ok_or_else(|| {
|
||||
CodexErr::InvalidRequest(format!(
|
||||
"unknown turn environment id `{}`",
|
||||
selected_environment.environment_id
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
Ok(ResolvedTurnEnvironments { turn_environments })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -105,4 +130,51 @@ mod tests {
|
||||
Vec::<TurnEnvironmentSelection>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_environment_selections_rejects_duplicate_ids() {
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
||||
let manager = EnvironmentManager::default_for_tests();
|
||||
|
||||
let err = resolve_environment_selections(
|
||||
&manager,
|
||||
&[
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: "local".to_string(),
|
||||
cwd: cwd.clone(),
|
||||
},
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: "local".to_string(),
|
||||
cwd: cwd.join("other"),
|
||||
},
|
||||
],
|
||||
)
|
||||
.expect_err("duplicate environment id should fail");
|
||||
|
||||
assert!(err.to_string().contains("duplicate"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolved_environment_selections_use_first_selection_as_primary() {
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
||||
let selected_cwd = cwd.join("selected");
|
||||
let manager = EnvironmentManager::default_for_tests();
|
||||
|
||||
let resolved = resolve_environment_selections(
|
||||
&manager,
|
||||
&[TurnEnvironmentSelection {
|
||||
environment_id: "local".to_string(),
|
||||
cwd: selected_cwd,
|
||||
}],
|
||||
)
|
||||
.expect("environment selections should resolve");
|
||||
|
||||
assert_eq!(
|
||||
resolved
|
||||
.primary_turn_environment()
|
||||
.expect("primary environment")
|
||||
.environment_id,
|
||||
"local"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,6 @@ use codex_utils_template::Template;
|
||||
use futures::future::BoxFuture;
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -90,7 +88,6 @@ pub(crate) enum GoalRuntimeEvent<'a> {
|
||||
TurnFinished {
|
||||
turn_context: &'a TurnContext,
|
||||
turn_completed: bool,
|
||||
tool_calls: u64,
|
||||
},
|
||||
MaybeContinueIfIdle,
|
||||
TaskAborted {
|
||||
@@ -112,7 +109,6 @@ pub(crate) struct GoalRuntimeState {
|
||||
accounting: Mutex<GoalAccountingSnapshot>,
|
||||
continuation_turn_id: Mutex<Option<String>>,
|
||||
pub(crate) continuation_lock: Semaphore,
|
||||
pub(crate) continuation_suppressed: AtomicBool,
|
||||
}
|
||||
|
||||
struct GoalContinuationCandidate {
|
||||
@@ -129,7 +125,6 @@ impl GoalRuntimeState {
|
||||
accounting: Mutex::new(GoalAccountingSnapshot::new()),
|
||||
continuation_turn_id: Mutex::new(None),
|
||||
continuation_lock: Semaphore::new(/*permits*/ 1),
|
||||
continuation_suppressed: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,8 +272,8 @@ impl Session {
|
||||
/// suppresses that steering, external mutations account best-effort before
|
||||
/// changing state, interrupts pause active goals, resumes reactivate paused
|
||||
/// goals, explicit maybe-continue events start idle goal continuation turns,
|
||||
/// and no-tool continuation turns suppress the next automatic continuation
|
||||
/// until user/tool/external activity resets it.
|
||||
/// and continuation turns with no counted autonomous activity suppress the
|
||||
/// next automatic continuation until user/tool/external activity resets it.
|
||||
pub(crate) fn goal_runtime_apply<'a>(
|
||||
self: &'a Arc<Self>,
|
||||
event: GoalRuntimeEvent<'a>,
|
||||
@@ -296,7 +291,6 @@ impl Session {
|
||||
turn_context,
|
||||
tool_name,
|
||||
} => Box::pin(async move {
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
if tool_name != codex_tools::UPDATE_GOAL_TOOL_NAME {
|
||||
self.account_thread_goal_progress(turn_context, BudgetLimitSteering::Allowed)
|
||||
.await?;
|
||||
@@ -304,7 +298,6 @@ impl Session {
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::ToolCompletedGoal { turn_context } => Box::pin(async move {
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
self.account_thread_goal_progress(turn_context, BudgetLimitSteering::Suppressed)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -312,9 +305,8 @@ impl Session {
|
||||
GoalRuntimeEvent::TurnFinished {
|
||||
turn_context,
|
||||
turn_completed,
|
||||
tool_calls,
|
||||
} => Box::pin(async move {
|
||||
self.finish_thread_goal_turn(turn_context, turn_completed, tool_calls)
|
||||
self.finish_thread_goal_turn(turn_context, turn_completed)
|
||||
.await;
|
||||
Ok(())
|
||||
}),
|
||||
@@ -331,7 +323,6 @@ impl Session {
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move {
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
if let Err(err) = self.account_thread_goal_before_external_mutation().await {
|
||||
tracing::warn!(
|
||||
"failed to account thread goal progress before external mutation: {err}"
|
||||
@@ -463,7 +454,6 @@ impl Session {
|
||||
let goal_status = goal.status;
|
||||
let goal_id = goal.goal_id.clone();
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
let newly_active_goal = goal_status == codex_state::ThreadGoalStatus::Active
|
||||
&& (replacing_goal
|
||||
@@ -532,7 +522,6 @@ impl Session {
|
||||
|
||||
let goal_id = goal.goal_id.clone();
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
|
||||
let current_token_usage = self.total_token_usage().await.unwrap_or_default();
|
||||
@@ -561,7 +550,6 @@ impl Session {
|
||||
) {
|
||||
match status {
|
||||
codex_state::ThreadGoalStatus::Active => {
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
match self.state_db_for_thread_goals().await {
|
||||
Ok(Some(state_db)) => {
|
||||
match state_db.get_thread_goal(self.conversation_id).await {
|
||||
@@ -608,7 +596,6 @@ impl Session {
|
||||
}
|
||||
|
||||
async fn clear_stopped_thread_goal_runtime_state(&self) {
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
let mut accounting = self.goal_runtime.accounting.lock().await;
|
||||
if let Some(turn) = accounting.turn.as_mut() {
|
||||
@@ -663,16 +650,6 @@ impl Session {
|
||||
turn_context: &TurnContext,
|
||||
token_usage: TokenUsage,
|
||||
) {
|
||||
if self
|
||||
.goal_runtime
|
||||
.continuation_turn_id
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.is_none_or(|turn_id| turn_id != &turn_context.sub_id)
|
||||
{
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
}
|
||||
self.goal_runtime.accounting.lock().await.turn = Some(GoalTurnAccountingSnapshot::new(
|
||||
turn_context.sub_id.clone(),
|
||||
token_usage,
|
||||
@@ -723,12 +700,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_thread_goal_continuation_suppression(&self) {
|
||||
self.goal_runtime
|
||||
.continuation_suppressed
|
||||
.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
async fn mark_thread_goal_continuation_turn_started(&self, turn_id: String) {
|
||||
*self.goal_runtime.continuation_turn_id.lock().await = Some(turn_id);
|
||||
}
|
||||
@@ -757,7 +728,6 @@ impl Session {
|
||||
self: &Arc<Self>,
|
||||
turn_context: &TurnContext,
|
||||
turn_completed: bool,
|
||||
turn_tool_calls: u64,
|
||||
) {
|
||||
if turn_completed
|
||||
&& let Err(err) = self
|
||||
@@ -767,15 +737,8 @@ impl Session {
|
||||
tracing::warn!("failed to account thread goal progress at turn end: {err}");
|
||||
}
|
||||
|
||||
if self
|
||||
.take_thread_goal_continuation_turn(&turn_context.sub_id)
|
||||
.await
|
||||
&& turn_tool_calls == 0
|
||||
{
|
||||
self.goal_runtime
|
||||
.continuation_suppressed
|
||||
.store(true, Ordering::SeqCst);
|
||||
}
|
||||
self.take_thread_goal_continuation_turn(&turn_context.sub_id)
|
||||
.await;
|
||||
if turn_completed {
|
||||
let mut accounting = self.goal_runtime.accounting.lock().await;
|
||||
if accounting
|
||||
@@ -1126,7 +1089,6 @@ impl Session {
|
||||
};
|
||||
let goal_id = goal.goal_id.clone();
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
let active_turn_id = self
|
||||
.active_turn_context()
|
||||
@@ -1255,16 +1217,6 @@ impl Session {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if self
|
||||
.goal_runtime
|
||||
.continuation_suppressed
|
||||
.load(Ordering::SeqCst)
|
||||
{
|
||||
tracing::debug!(
|
||||
"skipping active goal continuation because the last continuation made no tool calls"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let state_db = match self.state_db_for_thread_goals().await {
|
||||
Ok(Some(state_db)) => state_db,
|
||||
Ok(None) => {
|
||||
@@ -1578,7 +1530,7 @@ mod tests {
|
||||
assert!(prompt.contains("<untrusted_objective>\nfinish the stack\n</untrusted_objective>"));
|
||||
assert!(prompt.contains("Token budget: 10000"));
|
||||
assert!(prompt.contains("call update_goal with status \"complete\""));
|
||||
assert!(prompt.contains(
|
||||
assert!(!prompt.contains(
|
||||
"explain the blocker or next required input to the user and wait for new input"
|
||||
));
|
||||
assert!(!prompt.contains("budgetLimited"));
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(crate) mod session;
|
||||
pub use session::SteerInputError;
|
||||
mod codex_thread;
|
||||
mod compact_remote;
|
||||
mod config_lock;
|
||||
pub use codex_thread::CodexThread;
|
||||
pub use codex_thread::CodexThreadTurnContextOverrides;
|
||||
pub use codex_thread::ThreadConfigSnapshot;
|
||||
|
||||
@@ -3,6 +3,7 @@ use codex_config::config_toml::ConfigToml;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_thread_store::ListThreadsParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::LocalThreadStoreConfig;
|
||||
use codex_thread_store::ThreadSortKey;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use std::io;
|
||||
@@ -60,12 +61,10 @@ pub async fn maybe_migrate_personality(
|
||||
}
|
||||
|
||||
async fn has_recorded_sessions(codex_home: &Path, default_provider: &str) -> io::Result<bool> {
|
||||
let store = LocalThreadStore::new(codex_rollout::RolloutConfig {
|
||||
let store = LocalThreadStore::new(LocalThreadStoreConfig {
|
||||
codex_home: codex_home.to_path_buf(),
|
||||
sqlite_home: codex_home.to_path_buf(),
|
||||
cwd: codex_home.to_path_buf(),
|
||||
model_provider_id: default_provider.to_string(),
|
||||
generate_memories: false,
|
||||
default_model_provider_id: default_provider.to_string(),
|
||||
});
|
||||
if has_threads(&store, /*archived*/ false).await? {
|
||||
return Ok(true);
|
||||
|
||||
@@ -41,9 +41,9 @@ pub async fn build_prompt_input(
|
||||
SessionSource::Exec,
|
||||
Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
let thread_store = thread_store_from_config(&config);
|
||||
let thread = thread_manager.start_thread(config, thread_store).await?;
|
||||
let thread = thread_manager.start_thread(config).await?;
|
||||
|
||||
let output = build_prompt_input_from_session(thread.thread.codex.session.as_ref(), input).await;
|
||||
let shutdown = thread.thread.shutdown_and_wait().await;
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
use anyhow::Context;
|
||||
use codex_config::config_toml::ConfigLockfileToml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::types::MemoriesToml;
|
||||
use codex_features::AppsMcpPathOverrideConfigToml;
|
||||
use codex_features::Feature;
|
||||
use codex_features::FeatureToml;
|
||||
use codex_features::FeaturesToml;
|
||||
use codex_features::MultiAgentV2ConfigToml;
|
||||
use codex_protocol::ThreadId;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config_lock::ConfigLockReplayOptions;
|
||||
use crate::config_lock::clear_config_lock_debug_controls;
|
||||
use crate::config_lock::config_lockfile;
|
||||
use crate::config_lock::toml_round_trip;
|
||||
use crate::config_lock::validate_config_lock_replay;
|
||||
|
||||
use super::SessionConfiguration;
|
||||
|
||||
pub(crate) async fn validate_config_lock_if_configured(
|
||||
session_configuration: &SessionConfiguration,
|
||||
) -> anyhow::Result<()> {
|
||||
if session_configuration.session_source.is_non_root_agent() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(expected) = session_configuration
|
||||
.original_config_do_not_use
|
||||
.config_lock_toml
|
||||
.as_ref()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let actual = session_configuration.to_config_lockfile_toml()?;
|
||||
let config = session_configuration.original_config_do_not_use.as_ref();
|
||||
let options = ConfigLockReplayOptions {
|
||||
allow_codex_version_mismatch: config.config_lock_allow_codex_version_mismatch,
|
||||
};
|
||||
validate_config_lock_replay(expected, &actual, options)
|
||||
.context("config lock replay validation failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn export_config_lock_if_configured(
|
||||
session_configuration: &SessionConfiguration,
|
||||
conversation_id: ThreadId,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = session_configuration.original_config_do_not_use.as_ref();
|
||||
let Some(export_dir) = config.config_lock_export_dir.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let lock = session_configuration.to_config_lockfile_toml()?;
|
||||
let lock = toml::to_string_pretty(&lock).context("failed to serialize config lock")?;
|
||||
let path = export_dir.join(format!("{conversation_id}.config.lock.toml"));
|
||||
|
||||
tokio::fs::create_dir_all(export_dir)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create config lock export directory {}",
|
||||
export_dir.display()
|
||||
)
|
||||
})?;
|
||||
tokio::fs::write(&path, lock)
|
||||
.await
|
||||
.with_context(|| format!("failed to write config lock to {}", path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl SessionConfiguration {
|
||||
pub(crate) fn to_config_lockfile_toml(&self) -> anyhow::Result<ConfigLockfileToml> {
|
||||
Ok(config_lockfile(session_configuration_to_lock_config_toml(
|
||||
self,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn session_configuration_to_lock_config_toml(
|
||||
sc: &SessionConfiguration,
|
||||
) -> anyhow::Result<ConfigToml> {
|
||||
let config = sc.original_config_do_not_use.as_ref();
|
||||
// Start from the resolved layer stack, then patch in values that are only
|
||||
// known after session setup. Export and replay validation both use this
|
||||
// path, so every field here is part of the lockfile contract.
|
||||
let mut lock_config: ConfigToml = config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.try_into()
|
||||
.context("failed to deserialize effective config for config lock")?;
|
||||
|
||||
if config.config_lock_save_fields_resolved_from_model_catalog {
|
||||
save_session_resolved_fields(sc, &mut lock_config);
|
||||
}
|
||||
|
||||
save_config_resolved_fields(config, &mut lock_config)?;
|
||||
drop_lockfile_inputs(&mut lock_config);
|
||||
|
||||
Ok(lock_config)
|
||||
}
|
||||
|
||||
/// Saves values chosen during session construction from the model catalog,
|
||||
/// collaboration mode, and resolved prompt setup.
|
||||
///
|
||||
/// These values are not always present in the raw layer stack, so copy them
|
||||
/// from the live session when the lockfile should be fully self-contained.
|
||||
fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut ConfigToml) {
|
||||
lock_config.model = Some(sc.collaboration_mode.model().to_string());
|
||||
lock_config.model_reasoning_effort = sc.collaboration_mode.reasoning_effort();
|
||||
lock_config.model_reasoning_summary = sc.model_reasoning_summary;
|
||||
lock_config.service_tier = sc.service_tier;
|
||||
lock_config.instructions = Some(sc.base_instructions.clone());
|
||||
lock_config.developer_instructions = sc.developer_instructions.clone();
|
||||
lock_config.compact_prompt = sc.compact_prompt.clone();
|
||||
lock_config.personality = sc.personality;
|
||||
lock_config.approval_policy = Some(sc.approval_policy.value());
|
||||
lock_config.approvals_reviewer = Some(sc.approvals_reviewer);
|
||||
}
|
||||
|
||||
/// Saves values stored on `Config` after higher-level resolution,
|
||||
/// normalization, defaulting, or feature materialization.
|
||||
///
|
||||
/// Persist the resolved representation so replay compares against the behavior
|
||||
/// Codex actually ran with, not only the user-authored TOML inputs.
|
||||
fn save_config_resolved_fields(
|
||||
config: &Config,
|
||||
lock_config: &mut ConfigToml,
|
||||
) -> anyhow::Result<()> {
|
||||
lock_config.web_search = Some(config.web_search_mode.value());
|
||||
lock_config.model_provider = Some(config.model_provider_id.clone());
|
||||
lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort;
|
||||
lock_config.model_verbosity = config.model_verbosity;
|
||||
lock_config.include_permissions_instructions = Some(config.include_permissions_instructions);
|
||||
lock_config.include_apps_instructions = Some(config.include_apps_instructions);
|
||||
lock_config.include_environment_context = Some(config.include_environment_context);
|
||||
lock_config.background_terminal_max_timeout = Some(config.background_terminal_max_timeout);
|
||||
|
||||
// Feature aliases and feature configs need to be written in their resolved
|
||||
// form; otherwise replay can drift when a legacy key maps to the same
|
||||
// runtime feature.
|
||||
let features = lock_config
|
||||
.features
|
||||
.get_or_insert_with(FeaturesToml::default);
|
||||
features.materialize_resolved_enabled(config.features.get());
|
||||
let mut multi_agent_v2: MultiAgentV2ConfigToml =
|
||||
resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?;
|
||||
multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2));
|
||||
features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2));
|
||||
features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml {
|
||||
enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)),
|
||||
path: config.apps_mcp_path_override.clone(),
|
||||
}));
|
||||
lock_config.memories = Some(resolved_config_to_toml::<MemoriesToml>(
|
||||
&config.memories,
|
||||
"memories",
|
||||
)?);
|
||||
|
||||
let agents = lock_config.agents.get_or_insert_with(Default::default);
|
||||
// Multi-agent v2 owns thread fanout through its feature config. Preserve
|
||||
// the legacy agents.max_threads setting only when v2 is disabled.
|
||||
agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) {
|
||||
None
|
||||
} else {
|
||||
config.agent_max_threads
|
||||
};
|
||||
agents.max_depth = Some(config.agent_max_depth);
|
||||
agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds;
|
||||
agents.interrupt_message = Some(config.agent_interrupt_message_enabled);
|
||||
|
||||
lock_config
|
||||
.skills
|
||||
.get_or_insert_with(Default::default)
|
||||
.include_instructions = Some(config.include_skill_instructions);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_lockfile_inputs(lock_config: &mut ConfigToml) {
|
||||
// The lockfile should contain replayable values, not the profile,
|
||||
// debug-control, file-include, and environment-specific inputs that
|
||||
// produced those values in the original session.
|
||||
lock_config.profile = None;
|
||||
lock_config.profiles.clear();
|
||||
clear_config_lock_debug_controls(lock_config);
|
||||
lock_config.model_instructions_file = None;
|
||||
lock_config.experimental_instructions_file = None;
|
||||
lock_config.experimental_compact_prompt_file = None;
|
||||
lock_config.model_catalog_json = None;
|
||||
lock_config.sandbox_mode = None;
|
||||
lock_config.sandbox_workspace_write = None;
|
||||
lock_config.default_permissions = None;
|
||||
lock_config.permissions = None;
|
||||
lock_config.experimental_use_unified_exec_tool = None;
|
||||
lock_config.experimental_use_freeform_apply_patch = None;
|
||||
}
|
||||
|
||||
fn resolved_config_to_toml<Toml>(
|
||||
value: &impl serde::Serialize,
|
||||
label: &'static str,
|
||||
) -> anyhow::Result<Toml>
|
||||
where
|
||||
Toml: serde::de::DeserializeOwned + serde::Serialize,
|
||||
{
|
||||
toml_round_trip(value, label).map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_contains_prompts_and_materializes_features() {
|
||||
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
sc.base_instructions = "resolved instructions".to_string();
|
||||
sc.developer_instructions = Some("resolved developer instructions".to_string());
|
||||
sc.compact_prompt = Some("resolved compact prompt".to_string());
|
||||
|
||||
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
let lock = &lockfile.config;
|
||||
|
||||
assert_eq!(lock.instructions, Some(sc.base_instructions.clone()));
|
||||
assert_eq!(lock.developer_instructions, sc.developer_instructions);
|
||||
assert_eq!(lock.compact_prompt, sc.compact_prompt);
|
||||
assert_eq!(lock.model, Some(sc.collaboration_mode.model().to_string()));
|
||||
assert_eq!(
|
||||
lock.model_reasoning_effort,
|
||||
sc.collaboration_mode.reasoning_effort()
|
||||
);
|
||||
assert_eq!(lock.profile, None);
|
||||
assert!(lock.profiles.is_empty());
|
||||
assert!(
|
||||
lock.debug
|
||||
.as_ref()
|
||||
.is_none_or(|debug| debug.config_lockfile.is_none())
|
||||
);
|
||||
assert!(lock.memories.is_some());
|
||||
|
||||
let features = lock
|
||||
.features
|
||||
.as_ref()
|
||||
.expect("lock should materialize feature states");
|
||||
let feature_entries = features.entries();
|
||||
for spec in codex_features::FEATURES {
|
||||
assert_eq!(
|
||||
feature_entries.get(spec.key),
|
||||
Some(&sc.original_config_do_not_use.features.enabled(spec.id)),
|
||||
"{}",
|
||||
spec.key
|
||||
);
|
||||
}
|
||||
|
||||
let multi_agent_v2 = features
|
||||
.multi_agent_v2
|
||||
.as_ref()
|
||||
.expect("multi_agent_v2 config should be materialized");
|
||||
assert!(matches!(
|
||||
multi_agent_v2,
|
||||
FeatureToml::Config(MultiAgentV2ConfigToml {
|
||||
enabled: Some(false),
|
||||
max_concurrent_threads_per_session: Some(_),
|
||||
min_wait_timeout_ms: Some(_),
|
||||
usage_hint_enabled: Some(_),
|
||||
hide_spawn_agent_metadata: Some(_),
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
assert_eq!(lockfile.version, crate::config_lock::CONFIG_LOCK_VERSION);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_skips_session_values_when_model_catalog_fields_are_not_saved() {
|
||||
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let mut config = (*sc.original_config_do_not_use).clone();
|
||||
config.config_lock_save_fields_resolved_from_model_catalog = false;
|
||||
sc.original_config_do_not_use = Arc::new(config);
|
||||
sc.base_instructions = "catalog instructions".to_string();
|
||||
sc.developer_instructions = Some("catalog developer instructions".to_string());
|
||||
sc.compact_prompt = Some("catalog compact prompt".to_string());
|
||||
sc.service_tier = Some(codex_protocol::config_types::ServiceTier::Flex);
|
||||
|
||||
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
let lock = &lockfile.config;
|
||||
|
||||
assert_eq!(lock.model, None);
|
||||
assert_eq!(lock.model_reasoning_effort, None);
|
||||
assert_eq!(lock.model_reasoning_summary, None);
|
||||
assert_eq!(lock.service_tier, None);
|
||||
assert_eq!(lock.instructions, None);
|
||||
assert_eq!(lock.developer_instructions, None);
|
||||
assert_eq!(lock.compact_prompt, None);
|
||||
assert_eq!(lock.personality, None);
|
||||
assert_eq!(lock.approval_policy, None);
|
||||
assert_eq!(lock.approvals_reviewer, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_validation_reports_config_diff() {
|
||||
let sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let expected = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
let mut actual = expected.clone();
|
||||
actual.config.model = Some("different-model".to_string());
|
||||
|
||||
let error =
|
||||
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
|
||||
.expect_err("config drift should fail");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("replayed effective config does not match config lock"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("model = "), "{message}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_validation_rejects_codex_version_mismatch_by_default() {
|
||||
let sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let mut expected = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
expected.codex_version = "older-version".to_string();
|
||||
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
|
||||
let error =
|
||||
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
|
||||
.expect_err("version drift should fail");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("config lock Codex version mismatch"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("debug.config_lockfile.allow_codex_version_mismatch=true"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_validation_can_ignore_codex_version_mismatch() {
|
||||
let sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let mut expected = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
expected.codex_version = "older-version".to_string();
|
||||
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
|
||||
validate_config_lock_replay(
|
||||
&expected,
|
||||
&actual,
|
||||
ConfigLockReplayOptions {
|
||||
allow_codex_version_mismatch: true,
|
||||
},
|
||||
)
|
||||
.expect("version drift should be ignored");
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,19 @@ impl Session {
|
||||
let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config);
|
||||
let auth_statuses =
|
||||
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
|
||||
let mcp_runtime_environment = match turn_context.primary_environment() {
|
||||
Some(turn_environment) => McpRuntimeEnvironment::new(
|
||||
Arc::clone(&turn_environment.environment),
|
||||
turn_environment.cwd.to_path_buf(),
|
||||
),
|
||||
None => McpRuntimeEnvironment::new(
|
||||
self.services
|
||||
.environment_manager
|
||||
.default_environment()
|
||||
.unwrap_or_else(|| self.services.environment_manager.local_environment()),
|
||||
turn_context.cwd.to_path_buf(),
|
||||
),
|
||||
};
|
||||
{
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
guard.cancel();
|
||||
@@ -234,13 +247,7 @@ impl Session {
|
||||
turn_context.sub_id.clone(),
|
||||
self.get_tx_event(),
|
||||
turn_context.permission_profile(),
|
||||
McpRuntimeEnvironment::new(
|
||||
turn_context
|
||||
.environment
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.services.environment_manager.local_environment()),
|
||||
turn_context.cwd.to_path_buf(),
|
||||
),
|
||||
mcp_runtime_environment,
|
||||
config.codex_home.to_path_buf(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
tool_plugin_provenance,
|
||||
|
||||
@@ -30,8 +30,7 @@ use crate::context::NetworkRuleSaved;
|
||||
use crate::context::PermissionsInstructions;
|
||||
use crate::context::PersonalitySpecInstructions;
|
||||
use crate::default_skill_metadata_budget;
|
||||
use crate::environment_selection::selected_primary_environment;
|
||||
use crate::environment_selection::validate_environment_selections;
|
||||
use crate::environment_selection::ResolvedTurnEnvironments;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::installation_id::resolve_installation_id;
|
||||
use crate::parse_turn_item;
|
||||
@@ -113,7 +112,6 @@ use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::TurnContextNetworkItem;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
@@ -136,6 +134,7 @@ use codex_thread_store::LiveThreadInitGuard;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::ResumeThreadParams;
|
||||
use codex_thread_store::ThreadEventPersistenceMode;
|
||||
use codex_thread_store::ThreadPersistenceMetadata;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use futures::future::BoxFuture;
|
||||
@@ -184,6 +183,7 @@ use codex_protocol::error::Result as CodexResult;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::exec_output::StreamOutput;
|
||||
|
||||
mod config_lock;
|
||||
mod handlers;
|
||||
mod mcp;
|
||||
mod multi_agents;
|
||||
@@ -193,6 +193,8 @@ mod rollout_reconstruction;
|
||||
pub(crate) mod session;
|
||||
pub(crate) mod turn;
|
||||
pub(crate) mod turn_context;
|
||||
use self::config_lock::export_config_lock_if_configured;
|
||||
use self::config_lock::validate_config_lock_if_configured;
|
||||
#[cfg(test)]
|
||||
use self::handlers::submission_dispatch_span;
|
||||
use self::handlers::submission_loop;
|
||||
@@ -347,6 +349,7 @@ use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
|
||||
use codex_protocol::protocol::SkillToolDependency as ProtocolSkillToolDependency;
|
||||
use codex_protocol::protocol::StreamErrorEvent;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_protocol::protocol::TokenCountEvent;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
@@ -405,7 +408,7 @@ pub(crate) struct CodexSpawnArgs {
|
||||
pub(crate) parent_rollout_thread_trace: ThreadTraceContext,
|
||||
pub(crate) user_shell_override: Option<shell::Shell>,
|
||||
pub(crate) parent_trace: Option<W3cTraceContext>,
|
||||
pub(crate) environments: Vec<TurnEnvironmentSelection>,
|
||||
pub(crate) environment_selections: ResolvedTurnEnvironments,
|
||||
pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
|
||||
pub(crate) thread_store: Arc<dyn ThreadStore>,
|
||||
}
|
||||
@@ -462,18 +465,13 @@ impl Codex {
|
||||
inherited_exec_policy,
|
||||
parent_rollout_thread_trace,
|
||||
parent_trace: _,
|
||||
environments,
|
||||
environment_selections,
|
||||
analytics_events_client,
|
||||
thread_store,
|
||||
} = args;
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
validate_environment_selections(environment_manager.as_ref(), &environments)?;
|
||||
let environment =
|
||||
selected_primary_environment(environment_manager.as_ref(), &environments)?;
|
||||
let fs = environment
|
||||
.as_ref()
|
||||
.map(|environment| environment.get_filesystem());
|
||||
let fs = environment_selections.primary_filesystem();
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
@@ -496,8 +494,9 @@ impl Codex {
|
||||
let _ = config.features.disable(Feature::Collab);
|
||||
}
|
||||
|
||||
let primary_environment = environment_selections.primary_environment();
|
||||
let user_instructions = AgentsMdManager::new(&config)
|
||||
.user_instructions(environment.as_deref())
|
||||
.user_instructions(primary_environment.as_deref())
|
||||
.await;
|
||||
|
||||
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
|
||||
@@ -609,7 +608,7 @@ impl Codex {
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
environments,
|
||||
environments: environment_selections.to_selections(),
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name,
|
||||
app_server_client_name: None,
|
||||
@@ -2535,7 +2534,6 @@ impl Session {
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut developer_sections = Vec::<String>::with_capacity(8);
|
||||
let mut contextual_user_sections = Vec::<String>::with_capacity(2);
|
||||
let shell = self.user_shell();
|
||||
let (
|
||||
reference_context_item,
|
||||
previous_turn_settings,
|
||||
@@ -2696,7 +2694,7 @@ impl Session {
|
||||
.format_environment_context_subagents(self.conversation_id)
|
||||
.await;
|
||||
contextual_user_sections.push(
|
||||
crate::context::EnvironmentContext::from_turn_context(turn_context, shell.as_ref())
|
||||
crate::context::EnvironmentContext::from_turn_context(turn_context)
|
||||
.with_subagents(subagents)
|
||||
.render(),
|
||||
);
|
||||
|
||||
@@ -123,7 +123,6 @@ pub(super) async fn spawn_review_thread(
|
||||
reasoning_effort,
|
||||
reasoning_summary,
|
||||
session_source,
|
||||
environment: parent_turn_context.environment.clone(),
|
||||
environments: parent_turn_context.environments.clone(),
|
||||
tools_config,
|
||||
features: parent_turn_context.features.clone(),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::*;
|
||||
use crate::goals::GoalRuntimeState;
|
||||
use codex_otel::LEGACY_NOTIFY_CONFIGURED_METRIC;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// Context for an initialized model agent
|
||||
@@ -206,12 +208,7 @@ impl SessionConfiguration {
|
||||
.unwrap_or_else(|| self.cwd.clone());
|
||||
|
||||
let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path();
|
||||
next_configuration.cwd = absolute_cwd.clone();
|
||||
if cwd_changed
|
||||
&& let Some(primary_environment) = next_configuration.environments.first_mut()
|
||||
{
|
||||
primary_environment.cwd = absolute_cwd;
|
||||
}
|
||||
next_configuration.cwd = absolute_cwd;
|
||||
|
||||
if let Some(permission_profile) = updates.permission_profile.clone() {
|
||||
let active_permission_profile =
|
||||
@@ -400,6 +397,15 @@ impl Session {
|
||||
text: session_configuration.base_instructions.clone(),
|
||||
},
|
||||
dynamic_tools: session_configuration.dynamic_tools.clone(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
memory_mode: if config.memories.generate_memories {
|
||||
ThreadMemoryMode::Enabled
|
||||
} else {
|
||||
ThreadMemoryMode::Disabled
|
||||
},
|
||||
},
|
||||
event_persistence_mode,
|
||||
},
|
||||
)
|
||||
@@ -413,6 +419,15 @@ impl Session {
|
||||
rollout_path: resumed_history.rollout_path.clone(),
|
||||
history: Some(resumed_history.history.clone()),
|
||||
include_archived: true,
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
memory_mode: if config.memories.generate_memories {
|
||||
ThreadMemoryMode::Enabled
|
||||
} else {
|
||||
ThreadMemoryMode::Disabled
|
||||
},
|
||||
},
|
||||
event_persistence_mode,
|
||||
},
|
||||
)
|
||||
@@ -558,6 +573,24 @@ impl Session {
|
||||
}),
|
||||
});
|
||||
}
|
||||
let legacy_notify_configured = config
|
||||
.notify
|
||||
.as_ref()
|
||||
.is_some_and(|argv| !argv.is_empty() && !argv[0].is_empty());
|
||||
if legacy_notify_configured {
|
||||
post_session_configured_events.push(Event {
|
||||
id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent {
|
||||
summary:
|
||||
"`notify` is deprecated and will be removed in a future release."
|
||||
.to_string(),
|
||||
details: Some(
|
||||
"Switch to a `Stop` hook for end-of-turn automation. See https://developers.openai.com/codex/hooks."
|
||||
.to_string(),
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
for message in &config.startup_warnings {
|
||||
post_session_configured_events.push(Event {
|
||||
id: "".to_owned(),
|
||||
@@ -615,6 +648,9 @@ impl Session {
|
||||
if let Some(service_name) = session_configuration.metrics_service_name.as_deref() {
|
||||
session_telemetry = session_telemetry.with_metrics_service_name(service_name);
|
||||
}
|
||||
if legacy_notify_configured {
|
||||
session_telemetry.counter(LEGACY_NOTIFY_CONFIGURED_METRIC, /*inc*/ 1, &[]);
|
||||
}
|
||||
let network_proxy_audit_metadata = NetworkProxyAuditMetadata {
|
||||
conversation_id: Some(conversation_id.to_string()),
|
||||
app_version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||
@@ -705,6 +741,8 @@ impl Session {
|
||||
))
|
||||
.await;
|
||||
session_configuration.thread_name = thread_name.clone();
|
||||
validate_config_lock_if_configured(&session_configuration).await?;
|
||||
export_config_lock_if_configured(&session_configuration, conversation_id).await?;
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
let managed_network_requirements_configured = config
|
||||
.config_layer_stack
|
||||
@@ -920,6 +958,31 @@ impl Session {
|
||||
cancel_guard.cancel();
|
||||
*cancel_guard = CancellationToken::new();
|
||||
}
|
||||
let turn_environment = crate::environment_selection::resolve_environment_selections(
|
||||
sess.services.environment_manager.as_ref(),
|
||||
&session_configuration.environments,
|
||||
)
|
||||
.map_err(|err| {
|
||||
CodexErr::InvalidRequest(err.to_string().replace(
|
||||
"unknown turn environment id",
|
||||
"unknown stored MCP environment id",
|
||||
))
|
||||
})?
|
||||
.primary_turn_environment()
|
||||
.cloned();
|
||||
let mcp_runtime_environment = match turn_environment {
|
||||
Some(turn_environment) => McpRuntimeEnvironment::new(
|
||||
Arc::clone(&turn_environment.environment),
|
||||
turn_environment.cwd.to_path_buf(),
|
||||
),
|
||||
None => McpRuntimeEnvironment::new(
|
||||
sess.services
|
||||
.environment_manager
|
||||
.default_environment()
|
||||
.unwrap_or_else(|| sess.services.environment_manager.local_environment()),
|
||||
session_configuration.cwd.to_path_buf(),
|
||||
),
|
||||
};
|
||||
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
@@ -928,13 +991,7 @@ impl Session {
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
tx_event.clone(),
|
||||
session_configuration.permission_profile(),
|
||||
McpRuntimeEnvironment::new(
|
||||
sess.services
|
||||
.environment_manager
|
||||
.default_environment()
|
||||
.unwrap_or_else(|| sess.services.environment_manager.local_environment()),
|
||||
session_configuration.cwd.to_path_buf(),
|
||||
),
|
||||
mcp_runtime_environment,
|
||||
config.codex_home.to_path_buf(),
|
||||
codex_apps_tools_cache_key(auth),
|
||||
tool_plugin_provenance,
|
||||
|
||||
@@ -47,6 +47,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::protocol::NonSteerableTurnKind;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
use tracing::Span;
|
||||
@@ -118,6 +119,8 @@ use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_protocol::request_user_input::RequestUserInputAnswer;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::PathExt;
|
||||
use core_test_support::context_snapshot;
|
||||
@@ -136,6 +139,7 @@ use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_path_buf;
|
||||
use core_test_support::tracing::install_test_tracing;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
use opentelemetry::trace::TraceId;
|
||||
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
|
||||
@@ -1679,9 +1683,6 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
|
||||
.fork_thread(
|
||||
usize::MAX,
|
||||
fork_config.clone(),
|
||||
std::sync::Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(&fork_config),
|
||||
)),
|
||||
rollout_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
@@ -2720,6 +2721,7 @@ async fn wait_for_thread_rollback_failed(rx: &async_channel::Receiver<Event>) ->
|
||||
}
|
||||
|
||||
async fn attach_thread_persistence(session: &mut Session) -> PathBuf {
|
||||
let config = session.get_config().await;
|
||||
let live_thread = LiveThread::create(
|
||||
Arc::clone(&session.services.thread_store),
|
||||
CreateThreadParams {
|
||||
@@ -2728,6 +2730,15 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf {
|
||||
source: SessionSource::Exec,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
dynamic_tools: Vec::new(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
memory_mode: if config.memories.generate_memories {
|
||||
ThreadMemoryMode::Enabled
|
||||
} else {
|
||||
ThreadMemoryMode::Disabled
|
||||
},
|
||||
},
|
||||
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
||||
},
|
||||
)
|
||||
@@ -2938,6 +2949,7 @@ fn turn_environments_for_tests(
|
||||
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
environment: Arc::clone(environment),
|
||||
cwd: cwd.clone(),
|
||||
shell: "bash".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -3279,7 +3291,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_update_settings_keeps_runtime_cwds_absolute() {
|
||||
async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let updated_cwd = turn_context.cwd.join("project");
|
||||
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
|
||||
@@ -3305,6 +3317,91 @@ async fn session_update_settings_keeps_runtime_cwds_absolute() {
|
||||
assert_eq!(next_turn.config.cwd, updated_cwd);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relative_cwd_update_without_environments_resolves_under_session_cwd() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let original_cwd = {
|
||||
let mut state = session.state.lock().await;
|
||||
state.session_configuration.environments = Vec::new();
|
||||
state.session_configuration.cwd.clone()
|
||||
};
|
||||
let updated_cwd = original_cwd.join("project");
|
||||
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
|
||||
|
||||
session
|
||||
.update_settings(SessionSettingsUpdate {
|
||||
cwd: Some(PathBuf::from("project")),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("cwd update should succeed");
|
||||
|
||||
let state = session.state.lock().await;
|
||||
assert_eq!(state.session_configuration.cwd, updated_cwd);
|
||||
assert!(state.session_configuration.environments.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cwd_update_does_not_rewrite_sticky_environment_cwd() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let (original_cwd, environment_cwd) = {
|
||||
let mut state = session.state.lock().await;
|
||||
let original_cwd = state.session_configuration.cwd.clone();
|
||||
let environment_cwd = original_cwd.join("environment");
|
||||
state.session_configuration.environments = vec![TurnEnvironmentSelection {
|
||||
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
cwd: environment_cwd.clone(),
|
||||
}];
|
||||
(original_cwd, environment_cwd)
|
||||
};
|
||||
let updated_cwd = original_cwd.join("project");
|
||||
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
|
||||
|
||||
session
|
||||
.update_settings(SessionSettingsUpdate {
|
||||
cwd: Some(PathBuf::from("project")),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("cwd update should succeed");
|
||||
|
||||
let state = session.state.lock().await;
|
||||
assert_eq!(state.session_configuration.cwd, updated_cwd);
|
||||
assert_eq!(
|
||||
state.session_configuration.environments[0].cwd,
|
||||
environment_cwd
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absolute_cwd_update_with_turn_environment_is_allowed() {
|
||||
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
|
||||
let absolute_cwd = {
|
||||
let state = session.state.lock().await;
|
||||
state.session_configuration.cwd.join("absolute-turn")
|
||||
};
|
||||
std::fs::create_dir_all(absolute_cwd.as_path()).expect("create absolute turn dir");
|
||||
|
||||
let turn_context = session
|
||||
.new_turn_with_sub_id(
|
||||
"sub-1".to_string(),
|
||||
SessionSettingsUpdate {
|
||||
cwd: Some(absolute_cwd.to_path_buf()),
|
||||
environments: Some(vec![TurnEnvironmentSelection {
|
||||
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
cwd: absolute_cwd.clone(),
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("absolute cwd with explicit environments should succeed");
|
||||
|
||||
assert_eq!(turn_context.cwd, absolute_cwd);
|
||||
assert_eq!(turn_context.config.cwd, absolute_cwd);
|
||||
assert_eq!(turn_context.environments.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
@@ -3392,7 +3489,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(config.as_ref()),
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
|
||||
)),
|
||||
codex_rollout_trace::ThreadTraceContext::disabled(),
|
||||
)
|
||||
@@ -3539,7 +3636,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
state_db: None,
|
||||
live_thread: None,
|
||||
thread_store: Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(config.as_ref()),
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
|
||||
)),
|
||||
model_client: ModelClient::new(
|
||||
Some(auth_manager.clone()),
|
||||
@@ -3584,7 +3681,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
model_info,
|
||||
&models_manager,
|
||||
/*network*/ None,
|
||||
Some(environment),
|
||||
turn_environments,
|
||||
session_configuration.cwd.clone(),
|
||||
"turn_id".to_string(),
|
||||
@@ -3711,7 +3807,7 @@ async fn make_session_with_config_and_rx(
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(config.as_ref()),
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
|
||||
)),
|
||||
codex_rollout_trace::ThreadTraceContext::disabled(),
|
||||
)
|
||||
@@ -4323,23 +4419,24 @@ async fn turn_environments_set_primary_environment() {
|
||||
|
||||
let turn_environments = &turn_context.environments;
|
||||
assert_eq!(turn_environments.len(), 1);
|
||||
let turn_environment = turn_context
|
||||
.primary_environment()
|
||||
.expect("primary environment should be set");
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
turn_context
|
||||
.environment
|
||||
.as_ref()
|
||||
.expect("primary environment should be set"),
|
||||
&turn_environment.environment,
|
||||
&turn_environments[0].environment
|
||||
));
|
||||
assert!(!turn_context.environments.is_empty());
|
||||
assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path());
|
||||
assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_turn_uses_stored_thread_environments() {
|
||||
async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() {
|
||||
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
|
||||
let session_cwd = session.get_config().await.cwd.clone();
|
||||
let selected_cwd =
|
||||
AbsolutePathBuf::try_from(session.get_config().await.cwd.as_path().join("selected"))
|
||||
.expect("absolute path");
|
||||
AbsolutePathBuf::try_from(session_cwd.as_path().join("selected")).expect("absolute path");
|
||||
|
||||
{
|
||||
let mut state = session.state.lock().await;
|
||||
@@ -4353,15 +4450,15 @@ async fn default_turn_uses_stored_thread_environments() {
|
||||
|
||||
let turn_environments = &turn_context.environments;
|
||||
assert_eq!(turn_environments.len(), 1);
|
||||
let turn_environment = turn_context
|
||||
.primary_environment()
|
||||
.expect("primary environment should be set");
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
turn_context
|
||||
.environment
|
||||
.as_ref()
|
||||
.expect("primary environment should be set"),
|
||||
&turn_environment.environment,
|
||||
&turn_environments[0].environment
|
||||
));
|
||||
assert_eq!(turn_context.cwd, selected_cwd);
|
||||
assert_eq!(turn_context.config.cwd, selected_cwd);
|
||||
assert_eq!(turn_context.cwd, session_cwd);
|
||||
assert_eq!(turn_context.config.cwd, session_cwd);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4376,54 +4473,43 @@ async fn default_turn_honors_empty_stored_thread_environments() {
|
||||
|
||||
let turn_context = session.new_default_turn().await;
|
||||
|
||||
assert!(turn_context.environment.is_none());
|
||||
assert!(turn_context.primary_environment().is_none());
|
||||
assert!(turn_context.environments.is_empty());
|
||||
assert_eq!(turn_context.cwd, session_cwd);
|
||||
assert_eq!(turn_context.config.cwd, session_cwd);
|
||||
assert_eq!(turn_context.environments.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_turn_environments_use_first_as_primary_environment() {
|
||||
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
|
||||
let session_cwd = session.get_config().await.cwd.clone();
|
||||
let first_cwd =
|
||||
AbsolutePathBuf::try_from(session_cwd.as_path().join("first")).expect("absolute path");
|
||||
let second_cwd =
|
||||
AbsolutePathBuf::try_from(session_cwd.as_path().join("second")).expect("absolute path");
|
||||
async fn primary_environment_uses_first_turn_environment() {
|
||||
let (_session, mut turn_context) = make_session_and_context().await;
|
||||
let first_environment = turn_context.environments[0].clone();
|
||||
let second_cwd = turn_context.cwd.join("second");
|
||||
turn_context.environments.push(TurnEnvironment {
|
||||
environment_id: "second".to_string(),
|
||||
environment: Arc::clone(&first_environment.environment),
|
||||
cwd: second_cwd.clone(),
|
||||
shell: first_environment.shell.clone(),
|
||||
});
|
||||
|
||||
let turn_context = session
|
||||
.new_turn_with_sub_id(
|
||||
"sub-1".to_string(),
|
||||
SessionSettingsUpdate {
|
||||
environments: Some(vec![
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: "local".to_string(),
|
||||
cwd: first_cwd.clone(),
|
||||
},
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: "local".to_string(),
|
||||
cwd: second_cwd.clone(),
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("turn should start");
|
||||
|
||||
let turn_environments = &turn_context.environments;
|
||||
assert_eq!(turn_environments.len(), 2);
|
||||
assert_eq!(turn_environments[0].cwd, first_cwd);
|
||||
assert_eq!(turn_environments[1].cwd, second_cwd);
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
assert_eq!(
|
||||
turn_context
|
||||
.environment
|
||||
.as_ref()
|
||||
.expect("primary environment should be set"),
|
||||
&turn_environments[0].environment
|
||||
));
|
||||
assert_eq!(turn_context.cwd, first_cwd);
|
||||
assert_eq!(turn_context.config.cwd, first_cwd);
|
||||
.primary_environment()
|
||||
.expect("primary environment")
|
||||
.environment_id,
|
||||
first_environment.environment_id
|
||||
);
|
||||
assert_eq!(
|
||||
turn_context
|
||||
.environments
|
||||
.iter()
|
||||
.find(|environment| environment.environment_id == "second")
|
||||
.expect("second environment")
|
||||
.cwd,
|
||||
second_cwd
|
||||
);
|
||||
assert_eq!(turn_context.environments.len(), 2);
|
||||
assert_eq!(turn_context.environments[1].cwd, second_cwd);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4441,15 +4527,19 @@ async fn empty_turn_environments_clear_primary_environment() {
|
||||
.await
|
||||
.expect("turn should start");
|
||||
|
||||
assert!(turn_context.environment.is_none());
|
||||
assert!(turn_context.primary_environment().is_none());
|
||||
assert!(turn_context.environments.is_empty());
|
||||
assert_eq!(turn_context.cwd, session.get_config().await.cwd);
|
||||
assert_eq!(turn_context.config.cwd, session.get_config().await.cwd);
|
||||
assert_eq!(turn_context.environments.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_turn_environment_returns_error() {
|
||||
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
|
||||
let original_configuration = {
|
||||
let state = session.state.lock().await;
|
||||
state.session_configuration.clone()
|
||||
};
|
||||
|
||||
let err = session
|
||||
.new_turn_with_sub_id(
|
||||
@@ -4457,7 +4547,7 @@ async fn unknown_turn_environment_returns_error() {
|
||||
SessionSettingsUpdate {
|
||||
environments: Some(vec![TurnEnvironmentSelection {
|
||||
environment_id: "missing".to_string(),
|
||||
cwd: session.get_config().await.cwd.clone(),
|
||||
cwd: original_configuration.cwd.clone(),
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -4465,8 +4555,58 @@ async fn unknown_turn_environment_returns_error() {
|
||||
.await
|
||||
.expect_err("unknown environment should fail");
|
||||
|
||||
let current_configuration = {
|
||||
let state = session.state.lock().await;
|
||||
state.session_configuration.clone()
|
||||
};
|
||||
assert!(matches!(err, CodexErr::InvalidRequest(_)));
|
||||
assert!(err.to_string().contains("missing"));
|
||||
assert_eq!(current_configuration.cwd, original_configuration.cwd);
|
||||
assert_eq!(
|
||||
current_configuration.environments,
|
||||
original_configuration.environments
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_turn_environment_returns_error_without_mutating_session() {
|
||||
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
|
||||
let original_configuration = {
|
||||
let state = session.state.lock().await;
|
||||
state.session_configuration.clone()
|
||||
};
|
||||
|
||||
let err = session
|
||||
.new_turn_with_sub_id(
|
||||
"sub-1".to_string(),
|
||||
SessionSettingsUpdate {
|
||||
environments: Some(vec![
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: "local".to_string(),
|
||||
cwd: original_configuration.cwd.clone(),
|
||||
},
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: "local".to_string(),
|
||||
cwd: original_configuration.cwd.join("second"),
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("duplicate environment should fail");
|
||||
|
||||
let current_configuration = {
|
||||
let state = session.state.lock().await;
|
||||
state.session_configuration.clone()
|
||||
};
|
||||
assert!(matches!(err, CodexErr::InvalidRequest(_)));
|
||||
assert!(err.to_string().contains("duplicate"));
|
||||
assert_eq!(current_configuration.cwd, original_configuration.cwd);
|
||||
assert_eq!(
|
||||
current_configuration.environments,
|
||||
original_configuration.environments
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4574,6 +4714,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
|
||||
let (mut session, _turn_context) = make_session_and_context().await;
|
||||
let store = Arc::new(codex_thread_store::InMemoryThreadStore::default());
|
||||
let thread_store: Arc<dyn codex_thread_store::ThreadStore> = store.clone();
|
||||
let config = session.get_config().await;
|
||||
let live_thread = LiveThread::create(
|
||||
Arc::clone(&thread_store),
|
||||
CreateThreadParams {
|
||||
@@ -4582,6 +4723,15 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
|
||||
source: SessionSource::Exec,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
dynamic_tools: Vec::new(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
memory_mode: if config.memories.generate_memories {
|
||||
ThreadMemoryMode::Enabled
|
||||
} else {
|
||||
ThreadMemoryMode::Disabled
|
||||
},
|
||||
},
|
||||
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
||||
},
|
||||
)
|
||||
@@ -4968,7 +5118,7 @@ where
|
||||
state_db: None,
|
||||
live_thread: None,
|
||||
thread_store: Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(config.as_ref()),
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
|
||||
)),
|
||||
model_client: ModelClient::new(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
@@ -5013,7 +5163,6 @@ where
|
||||
model_info,
|
||||
&models_manager,
|
||||
/*network*/ None,
|
||||
Some(environment),
|
||||
turn_environments,
|
||||
session_configuration.cwd.clone(),
|
||||
"turn_id".to_string(),
|
||||
@@ -6941,7 +7090,7 @@ async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Result<()> {
|
||||
async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
@@ -6967,17 +7116,21 @@ async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Res
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
ev_assistant_message("msg-2", "I am still working on the benchmark note."),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-4"),
|
||||
ev_function_call(
|
||||
"call-complete-goal",
|
||||
"update_goal",
|
||||
r#"{"status":"complete"}"#,
|
||||
),
|
||||
ev_completed("resp-3"),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-2", "Goal complete."),
|
||||
ev_completed("resp-4"),
|
||||
ev_assistant_message("msg-3", "Goal complete."),
|
||||
ev_completed("resp-5"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
@@ -7001,7 +7154,7 @@ async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Res
|
||||
let event = test.codex.next_event().await?;
|
||||
if matches!(event.msg, EventMsg::TurnComplete(_)) {
|
||||
completed_turns += 1;
|
||||
if completed_turns == 2 {
|
||||
if completed_turns == 3 {
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
}
|
||||
@@ -7012,6 +7165,125 @@ async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn pending_request_user_input_does_not_spawn_extra_goal_continuation() -> anyhow::Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Goals)
|
||||
.expect("goal mode should be enableable in tests");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::DefaultModeRequestUserInput)
|
||||
.expect("default-mode request_user_input should be enableable in tests");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
"call-create-goal",
|
||||
"create_goal",
|
||||
r#"{"objective":"write a benchmark note"}"#,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "Draft ready."),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
ev_function_call(
|
||||
"call-ask-user",
|
||||
"request_user_input",
|
||||
r#"{"questions":[{"header":"Choice","id":"next_step","question":"Pick one","options":[{"label":"Outline","description":"Start with an outline."},{"label":"Draft","description":"Write a full draft."}]}]}"#,
|
||||
),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-4"),
|
||||
ev_function_call(
|
||||
"call-complete-goal",
|
||||
"update_goal",
|
||||
r#"{"status":"complete"}"#,
|
||||
),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-2", "Goal complete."),
|
||||
ev_completed("resp-5"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
environments: None,
|
||||
items: vec![UserInput::Text {
|
||||
text: "write a benchmark note".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let request_user_input_event = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::RequestUserInput(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(3, responses.requests().len());
|
||||
assert!(
|
||||
timeout(Duration::from_millis(200), test.codex.next_event())
|
||||
.await
|
||||
.is_err(),
|
||||
"waiting for request_user_input should keep the turn open without emitting more events"
|
||||
);
|
||||
assert_eq!(
|
||||
3,
|
||||
responses.requests().len(),
|
||||
"waiting for request_user_input should not start another continuation request"
|
||||
);
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInputAnswer {
|
||||
id: request_user_input_event.turn_id,
|
||||
response: RequestUserInputResponse {
|
||||
answers: std::collections::HashMap::from([(
|
||||
"next_step".to_string(),
|
||||
RequestUserInputAnswer {
|
||||
answers: vec!["Outline".to_string()],
|
||||
},
|
||||
)]),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut completed_turns = 0;
|
||||
timeout(Duration::from_secs(8), async {
|
||||
loop {
|
||||
let event = test.codex.next_event().await?;
|
||||
if matches!(event.msg, EventMsg::TurnComplete(_)) {
|
||||
completed_turns += 1;
|
||||
if completed_turns == 1 {
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
|
||||
assert_eq!(5, responses.requests().len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_total_token_usage(sess: &Session, total_token_usage: TokenUsage) {
|
||||
let mut state = sess.state.lock().await;
|
||||
state.set_token_info(Some(TokenUsageInfo {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::compact::InitialContextInjection;
|
||||
use crate::environment_selection::ResolvedTurnEnvironments;
|
||||
use crate::exec::ExecCapturePolicy;
|
||||
use crate::exec::ExecParams;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
@@ -729,7 +730,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
let thread_store = Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(&config),
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(&config),
|
||||
));
|
||||
|
||||
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
|
||||
@@ -754,7 +755,9 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
|
||||
user_shell_override: None,
|
||||
parent_trace: None,
|
||||
environments: Vec::new(),
|
||||
environment_selections: ResolvedTurnEnvironments {
|
||||
turn_environments: Vec::new(),
|
||||
},
|
||||
analytics_events_client: None,
|
||||
thread_store,
|
||||
})
|
||||
|
||||
@@ -71,6 +71,7 @@ use codex_hooks::HookEvent;
|
||||
use codex_hooks::HookEventAfterAgent;
|
||||
use codex_hooks::HookPayload;
|
||||
use codex_hooks::HookResult;
|
||||
use codex_otel::LEGACY_NOTIFY_RUN_METRIC;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
@@ -575,6 +576,13 @@ pub(crate) async fn run_turn(
|
||||
},
|
||||
})
|
||||
.await;
|
||||
if !hook_outcomes.is_empty() {
|
||||
turn_context.session_telemetry.counter(
|
||||
LEGACY_NOTIFY_RUN_METRIC,
|
||||
/*inc*/ 1,
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
let mut abort_message = None;
|
||||
for hook_outcome in hook_outcomes {
|
||||
@@ -1462,9 +1470,9 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
|
||||
| EventMsg::PatchApplyBegin(_)
|
||||
| EventMsg::PatchApplyUpdated(_)
|
||||
| EventMsg::PatchApplyEnd(_)
|
||||
| EventMsg::ViewImageToolCall(_)
|
||||
| EventMsg::ImageGenerationBegin(_)
|
||||
| EventMsg::ImageGenerationEnd(_)
|
||||
| EventMsg::ViewImageToolCall(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::RequestPermissions(_)
|
||||
| EventMsg::RequestUserInput(_)
|
||||
|
||||
@@ -34,6 +34,7 @@ pub(crate) struct TurnEnvironment {
|
||||
pub(crate) environment_id: String,
|
||||
pub(crate) environment: Arc<Environment>,
|
||||
pub(crate) cwd: AbsolutePathBuf,
|
||||
pub(crate) shell: String,
|
||||
}
|
||||
|
||||
impl TurnEnvironment {
|
||||
@@ -59,7 +60,6 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
pub(crate) reasoning_summary: ReasoningSummaryConfig,
|
||||
pub(crate) session_source: SessionSource,
|
||||
pub(crate) environment: Option<Arc<Environment>>,
|
||||
pub(crate) environments: Vec<TurnEnvironment>,
|
||||
/// The session's absolute working directory. All relative paths provided
|
||||
/// by the model as well as sandbox policies are resolved against this path
|
||||
@@ -106,6 +106,10 @@ impl TurnContext {
|
||||
self.permission_profile.network_sandbox_policy()
|
||||
}
|
||||
|
||||
pub(crate) fn primary_environment(&self) -> Option<&TurnEnvironment> {
|
||||
self.environments.first()
|
||||
}
|
||||
|
||||
pub(crate) fn sandbox_policy(&self) -> SandboxPolicy {
|
||||
let file_system_sandbox_policy = self.file_system_sandbox_policy();
|
||||
let network_sandbox_policy = self.network_sandbox_policy();
|
||||
@@ -230,7 +234,6 @@ impl TurnContext {
|
||||
reasoning_effort,
|
||||
reasoning_summary: self.reasoning_summary,
|
||||
session_source: self.session_source.clone(),
|
||||
environment: self.environment.clone(),
|
||||
environments: self.environments.clone(),
|
||||
cwd: self.cwd.clone(),
|
||||
current_date: self.current_date.clone(),
|
||||
@@ -432,7 +435,6 @@ impl Session {
|
||||
model_info: ModelInfo,
|
||||
models_manager: &SharedModelsManager,
|
||||
network: Option<NetworkProxy>,
|
||||
environment: Option<Arc<Environment>>,
|
||||
environments: Vec<TurnEnvironment>,
|
||||
cwd: AbsolutePathBuf,
|
||||
sub_id: String,
|
||||
@@ -474,7 +476,7 @@ impl Session {
|
||||
)
|
||||
.with_web_search_config(per_turn_config.web_search_config.clone())
|
||||
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
|
||||
.with_has_environment(environment.is_some())
|
||||
.with_has_environment(!environments.is_empty())
|
||||
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
@@ -522,7 +524,6 @@ impl Session {
|
||||
reasoning_effort,
|
||||
reasoning_summary,
|
||||
session_source,
|
||||
environment,
|
||||
environments,
|
||||
cwd,
|
||||
current_date: Some(current_date),
|
||||
@@ -564,10 +565,16 @@ impl Session {
|
||||
let mut state = self.state.lock().await;
|
||||
match state.session_configuration.clone().apply(&updates) {
|
||||
Ok(next) => {
|
||||
let effective_environments = updates
|
||||
let mut effective_environments = updates
|
||||
.environments
|
||||
.clone()
|
||||
.unwrap_or_else(|| next.environments.clone());
|
||||
if updates.environments.is_none() {
|
||||
Self::overlay_runtime_cwd_on_primary_environment(
|
||||
&mut effective_environments,
|
||||
&next.cwd,
|
||||
);
|
||||
}
|
||||
let turn_environments =
|
||||
self.resolve_turn_environments(&effective_environments)?;
|
||||
let previous_cwd = state.session_configuration.cwd.clone();
|
||||
@@ -641,27 +648,11 @@ impl Session {
|
||||
&self,
|
||||
environments: &[TurnEnvironmentSelection],
|
||||
) -> CodexResult<Vec<TurnEnvironment>> {
|
||||
let mut turn_environments = Vec::with_capacity(environments.len());
|
||||
for selected_environment in environments {
|
||||
let environment_id = selected_environment.environment_id.clone();
|
||||
let environment = self
|
||||
.services
|
||||
.environment_manager
|
||||
.get_environment(&environment_id)
|
||||
.ok_or_else(|| {
|
||||
CodexErr::InvalidRequest(format!(
|
||||
"unknown turn environment id `{environment_id}`"
|
||||
))
|
||||
})?;
|
||||
let cwd = selected_environment.cwd.clone();
|
||||
turn_environments.push(TurnEnvironment {
|
||||
environment_id,
|
||||
environment,
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(turn_environments)
|
||||
crate::environment_selection::resolve_environment_selections(
|
||||
self.services.environment_manager.as_ref(),
|
||||
environments,
|
||||
)
|
||||
.map(|resolved| resolved.turn_environments)
|
||||
}
|
||||
|
||||
async fn new_turn_from_configuration(
|
||||
@@ -672,8 +663,6 @@ impl Session {
|
||||
turn_environments: Vec<TurnEnvironment>,
|
||||
) -> Arc<TurnContext> {
|
||||
let primary_turn_environment = turn_environments.first();
|
||||
let environment = primary_turn_environment
|
||||
.map(|turn_environment| Arc::clone(&turn_environment.environment));
|
||||
let cwd = primary_turn_environment
|
||||
.map(|turn_environment| turn_environment.cwd.clone())
|
||||
.unwrap_or_else(|| session_configuration.cwd.clone());
|
||||
@@ -700,9 +689,8 @@ impl Session {
|
||||
.await;
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots);
|
||||
let fs = environment
|
||||
.as_ref()
|
||||
.map(|environment| environment.get_filesystem());
|
||||
let fs = primary_turn_environment
|
||||
.map(|turn_environment| turn_environment.environment.get_filesystem());
|
||||
let skills_outcome = Arc::new(
|
||||
self.services
|
||||
.skills_manager
|
||||
@@ -731,7 +719,6 @@ impl Session {
|
||||
)
|
||||
.then(|| started_proxy.proxy())
|
||||
}),
|
||||
environment,
|
||||
turn_environments,
|
||||
cwd,
|
||||
sub_id,
|
||||
@@ -773,14 +760,18 @@ impl Session {
|
||||
let state = self.state.lock().await;
|
||||
state.session_configuration.clone()
|
||||
};
|
||||
let turn_environments =
|
||||
match self.resolve_turn_environments(&session_configuration.environments) {
|
||||
Ok(turn_environments) => turn_environments,
|
||||
Err(err) => {
|
||||
warn!("failed to resolve stored session environments: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let mut effective_environments = session_configuration.environments.clone();
|
||||
Self::overlay_runtime_cwd_on_primary_environment(
|
||||
&mut effective_environments,
|
||||
&session_configuration.cwd,
|
||||
);
|
||||
let turn_environments = match self.resolve_turn_environments(&effective_environments) {
|
||||
Ok(turn_environments) => turn_environments,
|
||||
Err(err) => {
|
||||
warn!("failed to resolve stored session environments: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
self.new_turn_from_configuration(
|
||||
sub_id,
|
||||
@@ -790,4 +781,15 @@ impl Session {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn overlay_runtime_cwd_on_primary_environment(
|
||||
environments: &mut [TurnEnvironmentSelection],
|
||||
runtime_cwd: &AbsolutePathBuf,
|
||||
) {
|
||||
if let Some(turn_environment) = environments.first_mut()
|
||||
&& turn_environment.cwd != *runtime_cwd
|
||||
{
|
||||
turn_environment.cwd = runtime_cwd.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,14 +255,14 @@ pub(crate) async fn handle_output_item_done(
|
||||
}
|
||||
// No tool call: convert messages/reasoning into turn items and mark them as complete.
|
||||
Ok(None) => {
|
||||
if let Some(turn_item) = handle_non_tool_response_item(
|
||||
let turn_item = handle_non_tool_response_item(
|
||||
ctx.sess.as_ref(),
|
||||
ctx.turn_context.as_ref(),
|
||||
&item,
|
||||
plan_mode,
|
||||
)
|
||||
.await
|
||||
{
|
||||
.await;
|
||||
if let Some(turn_item) = turn_item {
|
||||
if previously_active_item.is_none() {
|
||||
let mut started_item = turn_item.clone();
|
||||
if let TurnItem::ImageGeneration(item) = &mut started_item {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user