diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 18e9aa434c..d1b1e5eb9c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -408,6 +408,7 @@ dependencies = [ "base64 0.22.1", "chrono", "codex-app-server-protocol", + "codex-config", "codex-core", "codex-features", "codex-login", @@ -1433,9 +1434,11 @@ dependencies = [ "codex-utils-cli", "codex-utils-json-to-toml", "codex-utils-pty", + "codex-utils-rustls-provider", "constant_time_eq", "core_test_support", "futures", + "gethostname", "hmac", "jsonwebtoken", "opentelemetry", @@ -1458,6 +1461,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "url", "uuid", "wiremock", ] @@ -1603,6 +1607,7 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", + "codex-config", "codex-connectors", "codex-core", "codex-git-utils", @@ -1705,6 +1710,7 @@ dependencies = [ "base64 0.22.1", "chrono", "codex-backend-client", + "codex-config", "codex-core", "codex-login", "codex-otel", @@ -1802,8 +1808,13 @@ dependencies = [ "anyhow", "codex-app-server-protocol", "codex-execpolicy", + "codex-features", + "codex-git-utils", + "codex-model-provider-info", + "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", + "dunce", "futures", "multimap", "pretty_assertions", @@ -1923,7 +1934,6 @@ dependencies = [ "regex-lite", "reqwest", "rmcp", - "schemars 0.8.22", "serde", "serde_json", "serial_test", @@ -2113,7 +2123,6 @@ dependencies = [ name = "codex-features" version = "0.0.0" dependencies = [ - "codex-login", "codex-otel", "codex-protocol", "pretty_assertions", @@ -2269,7 +2278,6 @@ dependencies = [ "rand 0.9.2", "regex-lite", "reqwest", - "schemars 0.8.22", "serde", "serde_json", "serial_test", @@ -2372,6 +2380,7 @@ dependencies = [ "codex-api", "codex-app-server-protocol", "codex-collaboration-mode-templates", + "codex-config", "codex-feedback", "codex-login", "codex-model-provider-info", @@ -2567,6 +2576,7 @@ dependencies = [ "anyhow", "axum", "codex-client", + "codex-config", "codex-keyring-store", "codex-protocol", "codex-utils-cargo-bin", @@ -2578,7 +2588,6 @@ dependencies = [ "pretty_assertions", "reqwest", "rmcp", - "schemars 0.8.22", "serde", "serde_json", "serial_test", diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..f7f24eacdf 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..9f1c46d800 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1042,6 +1042,17 @@ "null" ] }, + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerStatusDetail" + }, + { + "type": "null" + } + ], + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", @@ -1208,6 +1219,25 @@ } ] }, + "McpResourceReadParams": { + "properties": { + "server": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "uri" + ], + "type": "object" + }, "McpServerOauthLoginParams": { "properties": { "name": { @@ -1235,6 +1265,13 @@ ], "type": "object" }, + "McpServerStatusDetail": { + "enum": [ + "full", + "toolsAndAuthOnly" + ], + "type": "string" + }, "MergeStrategy": { "enum": [ "replace", @@ -4420,6 +4457,30 @@ "title": "McpServerStatus/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServer/resource/read" + ], + "title": "McpServer/resource/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpResourceReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/resource/readRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..116844171b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..80f2b226b7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1201,6 +1201,30 @@ "title": "McpServerStatus/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "mcpServer/resource/read" + ], + "title": "McpServer/resource/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/McpResourceReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/resource/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -8604,6 +8628,17 @@ "null" ] }, + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/McpServerStatusDetail" + }, + { + "type": "null" + } + ], + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", @@ -8929,6 +8964,43 @@ ], "type": "string" }, + "McpResourceReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "server": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "uri" + ], + "title": "McpResourceReadParams", + "type": "object" + }, + "McpResourceReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contents": { + "items": { + "$ref": "#/definitions/v2/ResourceContent" + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "title": "McpResourceReadResponse", + "type": "object" + }, "McpServerOauthLoginCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9044,6 +9116,13 @@ ], "type": "object" }, + "McpServerStatusDetail": { + "enum": [ + "full", + "toolsAndAuthOnly" + ], + "type": "string" + }, "McpServerStatusUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9473,6 +9552,12 @@ "null" ] }, + "dangerFullAccessDenylistOnly": { + "type": [ + "boolean", + "null" + ] + }, "dangerouslyAllowAllUnixSockets": { "type": [ "boolean", @@ -10643,6 +10728,57 @@ ], "type": "object" }, + "ResourceContent": { + "anyOf": [ + { + "properties": { + "_meta": true, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + { + "properties": { + "_meta": true, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + } + ], + "description": "Contents returned when reading a resource from an MCP server." + }, "ResourceTemplate": { "description": "A template description for resources available on the server.", "properties": { @@ -14329,6 +14465,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14502,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..eb4e9d7b18 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1776,6 +1776,30 @@ "title": "McpServerStatus/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServer/resource/read" + ], + "title": "McpServer/resource/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpResourceReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/resource/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -5427,6 +5451,17 @@ "null" ] }, + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerStatusDetail" + }, + { + "type": "null" + } + ], + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", @@ -5752,6 +5787,43 @@ ], "type": "string" }, + "McpResourceReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "server": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "uri" + ], + "title": "McpResourceReadParams", + "type": "object" + }, + "McpResourceReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contents": { + "items": { + "$ref": "#/definitions/ResourceContent" + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "title": "McpResourceReadResponse", + "type": "object" + }, "McpServerOauthLoginCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -5867,6 +5939,13 @@ ], "type": "object" }, + "McpServerStatusDetail": { + "enum": [ + "full", + "toolsAndAuthOnly" + ], + "type": "string" + }, "McpServerStatusUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -6296,6 +6375,12 @@ "null" ] }, + "dangerFullAccessDenylistOnly": { + "type": [ + "boolean", + "null" + ] + }, "dangerouslyAllowAllUnixSockets": { "type": [ "boolean", @@ -7466,6 +7551,57 @@ ], "type": "object" }, + "ResourceContent": { + "anyOf": [ + { + "properties": { + "_meta": true, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + { + "properties": { + "_meta": true, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + } + ], + "description": "Contents returned when reading a resource from an MCP server." + }, "ResourceTemplate": { "description": "A template description for resources available on the server.", "properties": { @@ -12184,6 +12320,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12357,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 614575a955..ae6eb1dc7d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -151,6 +151,12 @@ "null" ] }, + "dangerFullAccessDenylistOnly": { + "type": [ + "boolean", + "null" + ] + }, "dangerouslyAllowAllUnixSockets": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json index e78dbeac16..52149d9fb8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json @@ -1,5 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "McpServerStatusDetail": { + "enum": [ + "full", + "toolsAndAuthOnly" + ], + "type": "string" + } + }, "properties": { "cursor": { "description": "Opaque pagination cursor returned by a previous call.", @@ -8,6 +17,17 @@ "null" ] }, + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerStatusDetail" + }, + { + "type": "null" + } + ], + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json new file mode 100644 index 0000000000..0242d4148b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "server": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "uri" + ], + "title": "McpResourceReadParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadResponse.json new file mode 100644 index 0000000000..b1a4012344 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadResponse.json @@ -0,0 +1,69 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ResourceContent": { + "anyOf": [ + { + "properties": { + "_meta": true, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + { + "properties": { + "_meta": true, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + } + ], + "description": "Contents returned when reading a resource from an MCP server." + } + }, + "properties": { + "contents": { + "items": { + "$ref": "#/definitions/ResourceContent" + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "title": "McpResourceReadResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..4bdeebf573 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a1658..a79ac103c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a34..e8211b2521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688df..08c4696bb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8453207380..f6d67d5c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b72..1697435049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8f..e664490df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6bf..98e022c751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3b..f640c40969 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea1765..1c10ea3b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..fc1deb675f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..5b79f4df11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..ccbe93e9c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e33a986359..d12599d7a9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -33,6 +33,7 @@ import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; +import type { McpResourceReadParams } from "./v2/McpResourceReadParams"; import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; import type { ModelListParams } from "./v2/ModelListParams"; import type { PluginInstallParams } from "./v2/PluginInstallParams"; @@ -64,4 +65,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResourceContent.ts b/codex-rs/app-server-protocol/schema/typescript/ResourceContent.ts new file mode 100644 index 0000000000..60fe239dc4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ResourceContent.ts @@ -0,0 +1,17 @@ +// 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 { JsonValue } from "./serde_json/JsonValue"; + +/** + * Contents returned when reading a resource from an MCP server. + */ +export type ResourceContent = { +/** + * The URI of this resource. + */ +uri: string, mimeType?: string, text: string, _meta?: JsonValue, } | { +/** + * The URI of this resource. + */ +uri: string, mimeType?: string, blob: string, _meta?: JsonValue, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..7ffc15e83d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -55,6 +55,7 @@ export type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSumm export type { ReasoningSummary } from "./ReasoningSummary"; export type { RequestId } from "./RequestId"; export type { Resource } from "./Resource"; +export type { ResourceContent } from "./ResourceContent"; export type { ResourceTemplate } from "./ResourceTemplate"; export type { ResponseItem } from "./ResponseItem"; export type { ReviewDecision } from "./ReviewDecision"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts index 05c02c19f8..8225c462b1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts @@ -1,6 +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 { McpServerStatusDetail } from "./McpServerStatusDetail"; export type ListMcpServerStatusParams = { /** @@ -10,4 +11,9 @@ cursor?: string | null, /** * Optional page size; defaults to a server-defined value. */ -limit?: number | null, }; +limit?: number | null, +/** + * Controls how much MCP inventory data to fetch for each server. + * Defaults to `Full` when omitted. + */ +detail?: McpServerStatusDetail | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts new file mode 100644 index 0000000000..51d650d9bb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts @@ -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 McpResourceReadParams = { threadId: string, server: string, uri: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadResponse.ts new file mode 100644 index 0000000000..2af1dbcd09 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ResourceContent } from "../ResourceContent"; + +export type McpResourceReadResponse = { contents: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusDetail.ts new file mode 100644 index 0000000000..ab97cc2f31 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusDetail.ts @@ -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 McpServerStatusDetail = "full" | "toolsAndAuthOnly"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts index 5fc942bef5..c685b9539c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts @@ -29,4 +29,4 @@ unixSockets: { [key in string]?: NetworkUnixSocketPermission } | null, /** * Legacy compatibility view derived from `unix_sockets`. */ -allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; +allowUnixSockets: Array | null, allowLocalBinding: boolean | null, dangerFullAccessDenylistOnly: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 495572fd78..4e75a31e38 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -172,6 +172,8 @@ export type { McpElicitationTitledSingleSelectEnumSchema } from "./McpElicitatio export type { McpElicitationUntitledEnumItems } from "./McpElicitationUntitledEnumItems"; export type { McpElicitationUntitledMultiSelectEnumSchema } from "./McpElicitationUntitledMultiSelectEnumSchema"; export type { McpElicitationUntitledSingleSelectEnumSchema } from "./McpElicitationUntitledSingleSelectEnumSchema"; +export type { McpResourceReadParams } from "./McpResourceReadParams"; +export type { McpResourceReadResponse } from "./McpResourceReadResponse"; export type { McpServerElicitationAction } from "./McpServerElicitationAction"; export type { McpServerElicitationRequestParams } from "./McpServerElicitationRequestParams"; export type { McpServerElicitationRequestResponse } from "./McpServerElicitationRequestResponse"; @@ -181,6 +183,7 @@ export type { McpServerOauthLoginResponse } from "./McpServerOauthLoginResponse" export type { McpServerRefreshResponse } from "./McpServerRefreshResponse"; export type { McpServerStartupState } from "./McpServerStartupState"; export type { McpServerStatus } from "./McpServerStatus"; +export type { McpServerStatusDetail } from "./McpServerStatusDetail"; export type { McpServerStatusUpdatedNotification } from "./McpServerStatusUpdatedNotification"; export type { McpToolCallError } from "./McpToolCallError"; export type { McpToolCallProgressNotification } from "./McpToolCallProgressNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ebd1ba11c5..8fbff67893 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -459,6 +459,11 @@ client_request_definitions! { response: v2::ListMcpServerStatusResponse, }, + McpResourceRead => "mcpServer/resource/read" { + params: v2::McpResourceReadParams, + response: v2::McpResourceReadResponse, + }, + WindowsSandboxSetupStart => "windowsSandbox/setupStart" { params: v2::WindowsSandboxSetupStartParams, response: v2::WindowsSandboxSetupStartResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 99a8f6e626..10d55bb231 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -864,22 +864,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -888,15 +895,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -905,7 +915,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -915,13 +925,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -954,7 +968,7 @@ impl ThreadHistoryBuilder { if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { return; } - self.turns.push(turn.into()); + self.turns.push(Turn::from(turn)); } } @@ -964,6 +978,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1082,6 +1099,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1102,6 +1122,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1111,6 +1136,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1122,6 +1150,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1273,6 +1304,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1293,6 +1325,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1345,6 +1379,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1364,6 +1399,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1375,6 +1412,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1464,6 +1504,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1661,6 +1703,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1679,6 +1722,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1715,6 +1760,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1820,6 +1866,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1879,6 +1926,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1966,6 +2014,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2038,6 +2087,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2096,6 +2146,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2108,9 +2159,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2142,6 +2196,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2179,6 +2235,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2191,9 +2248,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2225,6 +2285,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2257,6 +2319,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2320,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2382,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2394,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2409,6 +2477,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2418,6 +2488,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2437,6 +2509,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2449,9 +2522,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2464,6 +2540,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2489,6 +2567,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2499,6 +2578,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2509,6 +2590,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2726,6 +2810,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2738,6 +2823,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2757,6 +2844,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2773,6 +2863,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2791,6 +2882,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2826,6 +2919,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2839,6 +2933,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2869,6 +2965,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2884,6 +2981,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index cdc78647a1..61b7db45e2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -29,6 +29,7 @@ use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::mcp::Resource as McpResource; +pub use codex_protocol::mcp::ResourceContent as McpResourceContent; use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; use codex_protocol::mcp::Tool as McpTool; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; @@ -884,6 +885,7 @@ pub struct NetworkRequirements { /// Legacy compatibility view derived from `unix_sockets`. pub allow_unix_sockets: Option>, pub allow_local_binding: Option, + pub danger_full_access_denylist_only: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] @@ -1949,6 +1951,18 @@ pub struct ListMcpServerStatusParams { /// Optional page size; defaults to a server-defined value. #[ts(optional = nullable)] pub limit: Option, + /// Controls how much MCP inventory data to fetch for each server. + /// Defaults to `Full` when omitted. + #[ts(optional = nullable)] + pub detail: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum McpServerStatusDetail { + Full, + ToolsAndAuthOnly, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1972,6 +1986,22 @@ pub struct ListMcpServerStatusResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadParams { + pub thread_id: String, + pub server: String, + pub uri: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadResponse { + pub contents: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -3693,6 +3723,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -7782,6 +7821,7 @@ mod tests { dangerously_allow_all_unix_sockets: None, domains: None, managed_allowed_domains_only: None, + danger_full_access_denylist_only: None, allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["blocked.example.com".to_string()]), unix_sockets: None, @@ -7808,6 +7848,7 @@ mod tests { ), ])), managed_allowed_domains_only: Some(true), + danger_full_access_denylist_only: Some(true), allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["blocked.example.com".to_string()]), unix_sockets: Some(BTreeMap::from([ @@ -7838,6 +7879,7 @@ mod tests { "blocked.example.com": "deny" }, "managedAllowedDomainsOnly": true, + "dangerFullAccessDenylistOnly": true, "allowedDomains": ["api.openai.com"], "deniedDomains": ["blocked.example.com"], "unixSockets": { diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 4db6b81a21..5dc3a31485 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -57,10 +57,12 @@ codex-state = { workspace = true } codex-tools = { 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"] } @@ -81,6 +83,7 @@ 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 } uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c24c3fd7e8..6fedd73d98 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -25,6 +25,7 @@ Supported transports: - stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL) - websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**) +- off (`--listen off`): do not expose a local transport When running with `--listen ws://IP:PORT`, the same listener also serves basic HTTP health probes: @@ -186,7 +187,8 @@ Example with notification opt-out: - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. -- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. +- `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports cursor+limit pagination. If `detail` is omitted, the server defaults to `full`. +- `mcpServer/resource/read` — read a resource from a thread's configured MCP server by `threadId`, `server`, and `uri`, returning text/blob resource `contents`. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `config/read` — fetch the effective config on disk after resolving config layering. @@ -194,7 +196,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home). - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/app_server_tracing.rs b/codex-rs/app-server/src/app_server_tracing.rs index 26fe8ca999..b06a8e52c4 100644 --- a/codex-rs/app-server/src/app_server_tracing.rs +++ b/codex-rs/app-server/src/app_server_tracing.rs @@ -86,6 +86,7 @@ fn transport_name(transport: AppServerTransport) -> &'static str { match transport { AppServerTransport::Stdio => "stdio", AppServerTransport::WebSocket { .. } => "websocket", + AppServerTransport::Off => "off", } } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 01eb4bd578..141ee78bdb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -127,6 +127,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -190,6 +192,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -201,14 +206,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1704,7 +1716,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1866,11 +1885,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1878,8 +1904,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2073,6 +2102,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2083,22 +2113,40 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: turn_summary.started_at, + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: turn_summary.started_at, + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2871,6 +2919,9 @@ mod tests { Arc::new(Mutex::new(ThreadState::default())) } + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + async fn recv_broadcast_message( rx: &mut mpsc::Receiver, ) -> Result { @@ -2884,6 +2935,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), @@ -2948,7 +3017,7 @@ mod tests { id: event_turn_id, msg: EventMsg::GuardianAssessment(assessment), }, - self.conversation_id.clone(), + self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), self.outgoing.clone(), @@ -3648,10 +3717,25 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))); + } handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3663,6 +3747,9 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3696,6 +3783,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3707,6 +3795,8 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3740,6 +3830,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3758,6 +3849,8 @@ mod tests { additional_details: None, }) ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -4000,7 +4093,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -4014,11 +4114,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 86c2218bc8..920fcbeceb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -76,11 +76,14 @@ use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::MarketplaceInterface; +use codex_app_server_protocol::McpResourceReadParams; +use codex_app_server_protocol::McpResourceReadResponse; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; use codex_app_server_protocol::McpServerOauthLoginParams; use codex_app_server_protocol::McpServerOauthLoginResponse; use codex_app_server_protocol::McpServerRefreshResponse; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; @@ -245,9 +248,10 @@ use codex_login::default_client::set_default_client_residency_requirement; use codex_login::login_with_api_key; use codex_login::request_device_code; use codex_login::run_login_server; +use codex_mcp::mcp::McpSnapshotDetail; use codex_mcp::mcp::auth::discover_supported_scopes; use codex_mcp::mcp::auth::resolve_oauth_scopes; -use codex_mcp::mcp::collect_mcp_snapshot; +use codex_mcp::mcp::collect_mcp_snapshot_with_detail; use codex_mcp::mcp::effective_mcp_servers; use codex_mcp::mcp::qualified_mcp_tool_name_prefix; use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; @@ -474,22 +478,13 @@ pub(crate) struct CodexMessageProcessorArgs { } impl CodexMessageProcessor { - pub(crate) fn clear_plugin_related_caches(&self) { - self.thread_manager.plugins_manager().clear_cache(); - self.thread_manager.skills_manager().clear_cache(); + pub(crate) fn handle_config_mutation(&self) { + self.clear_plugin_related_caches(); } - pub(crate) async fn maybe_start_plugin_startup_tasks_for_latest_config(&self) { - match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => self - .thread_manager - .plugins_manager() - .maybe_start_plugin_startup_tasks_for_config( - &config, - self.thread_manager.auth_manager(), - ), - Err(err) => warn!("failed to load latest config for plugin startup tasks: {err:?}"), - } + fn clear_plugin_related_caches(&self) { + self.thread_manager.plugins_manager().clear_cache(); + self.thread_manager.skills_manager().clear_cache(); } fn current_account_updated_notification(&self) -> AccountUpdatedNotification { @@ -889,6 +884,10 @@ impl CodexMessageProcessor { self.list_mcp_server_status(to_connection_request_id(request_id), params) .await; } + ClientRequest::McpResourceRead { request_id, params } => { + self.read_mcp_resource(to_connection_request_id(request_id), params) + .await; + } ClientRequest::WindowsSandboxSetupStart { request_id, params } => { self.windows_sandbox_setup_start(to_connection_request_id(request_id), params) .await; @@ -2754,24 +2753,6 @@ impl CodexMessageProcessor { if state_db_ctx.is_none() { state_db_ctx = get_state_db(&self.config).await; } - if state_db_ctx.is_none() { - match StateRuntime::init( - self.config.sqlite_home.clone(), - self.config.model_provider_id.clone(), - ) - .await - { - Ok(ctx) => { - state_db_ctx = Some(ctx); - } - Err(err) => { - warn!( - "failed to initialize state db for thread metadata update at {}: {err}", - self.config.sqlite_home.display() - ); - } - } - } let Some(state_db_ctx) = state_db_ctx else { self.send_internal_error( request_id, @@ -5190,10 +5171,16 @@ impl CodexMessageProcessor { mcp_config: codex_mcp::mcp::McpConfig, auth: Option, ) { - let snapshot = collect_mcp_snapshot( + let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { + McpServerStatusDetail::Full => McpSnapshotDetail::Full, + McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly, + }; + + let snapshot = collect_mcp_snapshot_with_detail( &mcp_config, auth.as_ref(), request_id.request_id.to_string(), + detail, ) .await; @@ -5316,6 +5303,58 @@ impl CodexMessageProcessor { outgoing.send_response(request_id, response).await; } + async fn read_mcp_resource( + &self, + request_id: ConnectionRequestId, + params: McpResourceReadParams, + ) { + let outgoing = Arc::clone(&self.outgoing); + let (_, thread) = match self.load_thread(¶ms.thread_id).await { + Ok(thread) => thread, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + tokio::spawn(async move { + let result = thread.read_mcp_resource(¶ms.server, ¶ms.uri).await; + match result { + Ok(result) => match serde_json::from_value::(result) { + Ok(response) => { + outgoing.send_response(request_id, response).await; + } + Err(error) => { + outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to deserialize MCP resource read response: {error}" + ), + data: None, + }, + ) + .await; + } + }, + Err(error) => { + outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("{error:#}"), + data: None, + }, + ) + .await; + } + } + }); + } + async fn send_invalid_request_error(&self, request_id: ConnectionRequestId, message: String) { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -5651,7 +5690,11 @@ impl CodexMessageProcessor { .set_enabled(Feature::Apps, thread.enabled(Feature::Apps)); } - if !config.features.apps_enabled(Some(&self.auth_manager)).await { + let auth = self.auth_manager.auth().await; + if !config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth)) + { self.outgoing .send_response( request_id, @@ -5982,6 +6025,7 @@ impl CodexMessageProcessor { force_remote_sync, } = params; let roots = cwds.unwrap_or_default(); + plugins_manager.maybe_start_non_curated_plugin_cache_refresh_for_roots(&roots); let mut config = match self.load_latest_config(/*fallback_cwd*/ None).await { Ok(config) => config, @@ -6318,9 +6362,11 @@ impl CodexMessageProcessor { } let plugin_apps = load_plugin_apps(result.installed_path.as_path()); + let auth = self.auth_manager.auth().await; let apps_needing_auth = if plugin_apps.is_empty() - || !config.features.apps_enabled(Some(&self.auth_manager)).await - { + || !config.features.apps_enabled_for_auth( + auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), + ) { Vec::new() } else { let (all_connectors_result, accessible_connectors_result) = tokio::join!( @@ -6611,6 +6657,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6947,6 +6996,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -9596,6 +9648,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index ec2cf82cc1..4f9a800243 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -449,6 +449,7 @@ fn map_network_requirements_to_api( .collect() }), managed_allowed_domains_only: network.managed_allowed_domains_only, + danger_full_access_denylist_only: network.danger_full_access_denylist_only, allowed_domains, denied_domains, unix_sockets: network.unix_sockets.map(|unix_sockets| { @@ -594,6 +595,7 @@ mod tests { ]), }), managed_allowed_domains_only: Some(false), + danger_full_access_denylist_only: Some(true), unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml { entries: std::collections::BTreeMap::from([( "/tmp/proxy.sock".to_string(), @@ -653,6 +655,7 @@ mod tests { ("example.com".to_string(), NetworkDomainPermission::Deny), ])), managed_allowed_domains_only: Some(false), + danger_full_access_denylist_only: Some(true), allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["example.com".to_string()]), unix_sockets: Some(std::collections::BTreeMap::from([( @@ -687,6 +690,7 @@ mod tests { dangerously_allow_all_unix_sockets: None, domains: None, managed_allowed_domains_only: None, + danger_full_access_denylist_only: None, unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml { entries: std::collections::BTreeMap::from([( "/tmp/ignored.sock".to_string(), @@ -710,6 +714,7 @@ mod tests { dangerously_allow_all_unix_sockets: None, domains: None, managed_allowed_domains_only: None, + danger_full_access_denylist_only: None, allowed_domains: None, denied_domains: None, unix_sockets: Some(std::collections::BTreeMap::from([( diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..5d8cf052c5 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -80,6 +80,7 @@ use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; +use codex_login::AuthManager; use codex_protocol::protocol::SessionSource; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -379,6 +380,8 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { }); let processor_outgoing = Arc::clone(&outgoing_message_sender); + let auth_manager = + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { let mut processor = MessageProcessor::new(MessageProcessorArgs { @@ -393,7 +396,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { log_db: None, config_warnings: args.config_warnings, session_source: args.session_source, - enable_codex_api_key_env: args.enable_codex_api_key_env, + auth_manager, rpc_transport: AppServerRpcTransport::InProcess, }); let mut thread_created_rx = processor.thread_created_receiver(); @@ -823,6 +826,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 8c3742adcc..5e02cdb948 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -7,6 +7,8 @@ use codex_core::config::ConfigBuilder; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::LoaderOverrides; +use codex_features::Feature; +use codex_login::AuthManager; use codex_utils_cli::CliConfigOverrides; use std::collections::HashMap; use std::collections::HashSet; @@ -28,6 +30,7 @@ use crate::transport::OutboundConnectionState; use crate::transport::TransportEvent; use crate::transport::auth::policy_from_settings; use crate::transport::route_outgoing_envelope; +use crate::transport::start_remote_control; use crate::transport::start_stdio_connection; use crate::transport::start_websocket_acceptor; use codex_analytics::AppServerRpcTransport; @@ -42,10 +45,10 @@ use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::TextRange as CoreTextRange; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; -use codex_login::AuthManager; use codex_protocol::protocol::SessionSource; use codex_state::log_db; use tokio::sync::mpsc; +use tokio::sync::oneshot; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; @@ -396,11 +399,8 @@ pub async fn run_main_with_transport( } } - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - ); + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); cloud_requirements_loader( auth_manager, config.chatgpt_base_url, @@ -502,13 +502,13 @@ pub async fn run_main_with_transport( let feedback_layer = feedback.logger_layer(); let feedback_metadata_layer = feedback.metadata_layer(); - let log_db = codex_state::StateRuntime::init( + let state_db = codex_state::StateRuntime::init( config.sqlite_home.clone(), config.model_provider_id.clone(), ) .await - .ok() - .map(log_db::start); + .ok(); + let log_db = state_db.clone().map(log_db::start); let log_db_layer = log_db .clone() .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); @@ -535,11 +535,18 @@ pub async fn run_main_with_transport( let single_client_mode = matches!(&transport, AppServerTransport::Stdio); let shutdown_when_no_connections = single_client_mode; let graceful_signal_restart_enabled = !single_client_mode; + let mut app_server_client_name_rx = None; match transport { AppServerTransport::Stdio => { - start_stdio_connection(transport_event_tx.clone(), &mut transport_accept_handles) - .await?; + let (stdio_client_name_tx, stdio_client_name_rx) = oneshot::channel::(); + app_server_client_name_rx = Some(stdio_client_name_rx); + start_stdio_connection( + transport_event_tx.clone(), + &mut transport_accept_handles, + stdio_client_name_tx, + ) + .await?; } AppServerTransport::WebSocket { bind_address } => { let accept_handle = start_websocket_acceptor( @@ -551,6 +558,29 @@ pub async fn run_main_with_transport( .await?; transport_accept_handles.push(accept_handle); } + AppServerTransport::Off => {} + } + + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + + if config.features.enabled(Feature::RemoteControl) { + let accept_handle = start_remote_control( + config.chatgpt_base_url.clone(), + state_db.clone(), + auth_manager.clone(), + transport_event_tx.clone(), + transport_shutdown_token.clone(), + app_server_client_name_rx, + ) + .await?; + transport_accept_handles.push(accept_handle); + } + if transport_accept_handles.is_empty() { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "no transport configured; use --listen or enable remote control", + )); } let outbound_handle = tokio::spawn(async move { @@ -611,6 +641,8 @@ pub async fn run_main_with_transport( let processor_handle = tokio::spawn({ let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let outbound_control_tx = outbound_control_tx; + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone(); let loader_overrides = loader_overrides_for_config_api; let mut processor = MessageProcessor::new(MessageProcessorArgs { @@ -625,7 +657,7 @@ pub async fn run_main_with_transport( log_db, config_warnings, session_source, - enable_codex_api_key_env: false, + auth_manager, rpc_transport: analytics_rpc_transport(transport), }); let mut thread_created_rx = processor.thread_created_receiver(); @@ -853,7 +885,9 @@ pub async fn run_main_with_transport( fn analytics_rpc_transport(transport: AppServerTransport) -> AppServerRpcTransport { match transport { AppServerTransport::Stdio => AppServerRpcTransport::Stdio, - AppServerTransport::WebSocket { .. } => AppServerRpcTransport::Websocket, + AppServerTransport::WebSocket { .. } | AppServerTransport::Off => { + AppServerRpcTransport::Websocket + } } } diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index fa95f973ea..9a23680fb9 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -16,7 +16,7 @@ const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH" #[derive(Debug, Parser)] struct AppServerArgs { /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `ws://IP:PORT`. + /// `ws://IP:PORT`, `off`. #[arg( long = "listen", value_name = "URL", diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..15df5a2a54 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -193,7 +193,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) log_db: Option, pub(crate) config_warnings: Vec, pub(crate) session_source: SessionSource, - pub(crate) enable_codex_api_key_env: bool, + pub(crate) auth_manager: Arc, pub(crate) rpc_transport: AppServerRpcTransport, } @@ -213,17 +213,12 @@ impl MessageProcessor { log_db, config_warnings, session_source, - enable_codex_api_key_env, + auth_manager, rpc_transport, } = args; - let auth_manager = AuthManager::shared_with_external_auth( - config.codex_home.clone(), - enable_codex_api_key_env, - config.cli_auth_credentials_store_mode, - Arc::new(ExternalAuthRefreshBridge { - outgoing: outgoing.clone(), - }), - ); + auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { + outgoing: outgoing.clone(), + })); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -235,7 +230,6 @@ impl MessageProcessor { }, environment_manager, )); - auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); let analytics_events_client = AnalyticsEventsClient::new( Arc::clone(&auth_manager), config.chatgpt_base_url.trim_end_matches('/').to_string(), @@ -871,16 +865,8 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ConfigValueWriteParams, ) { - match self.config_api.write_value(params).await { - Ok(response) => { - self.codex_message_processor.clear_plugin_related_caches(); - self.codex_message_processor - .maybe_start_plugin_startup_tasks_for_latest_config() - .await; - self.outgoing.send_response(request_id, response).await; - } - Err(error) => self.outgoing.send_error(request_id, error).await, - } + let result = self.config_api.write_value(params).await; + self.handle_config_mutation_result(request_id, result).await } async fn handle_config_batch_write( @@ -888,8 +874,8 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ConfigBatchWriteParams, ) { - self.handle_config_mutation_result(request_id, self.config_api.batch_write(params).await) - .await; + let result = self.config_api.batch_write(params).await; + self.handle_config_mutation_result(request_id, result).await; } async fn handle_experimental_feature_enablement_set( @@ -898,23 +884,15 @@ impl MessageProcessor { params: ExperimentalFeatureEnablementSetParams, ) { let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true); - match self + let result = self .config_api .set_experimental_feature_enablement(params) - .await - { - Ok(response) => { - self.codex_message_processor.clear_plugin_related_caches(); - self.codex_message_processor - .maybe_start_plugin_startup_tasks_for_latest_config() - .await; - self.outgoing.send_response(request_id, response).await; - if should_refresh_apps_list { - self.refresh_apps_list_after_experimental_feature_enablement_set() - .await; - } - } - Err(error) => self.outgoing.send_error(request_id, error).await, + .await; + let is_ok = result.is_ok(); + self.handle_config_mutation_result(request_id, result).await; + if should_refresh_apps_list && is_ok { + self.refresh_apps_list_after_experimental_feature_enablement_set() + .await; } } @@ -933,7 +911,11 @@ impl MessageProcessor { return; } }; - if !config.features.apps_enabled(Some(&self.auth_manager)).await { + let auth = self.auth_manager.auth().await; + if !config.features.apps_enabled_for_auth( + auth.as_ref() + .is_some_and(codex_login::CodexAuth::is_chatgpt_auth), + ) { return; } @@ -987,10 +969,7 @@ impl MessageProcessor { ) { match result { Ok(response) => { - self.codex_message_processor.clear_plugin_related_caches(); - self.codex_message_processor - .maybe_start_plugin_startup_tasks_for_latest_config() - .await; + self.codex_message_processor.handle_config_mutation(); self.outgoing.send_response(request_id, response).await; } Err(error) => self.outgoing.send_error(request_id, error).await, diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..2e8781606f 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -27,6 +27,7 @@ use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; +use codex_login::AuthManager; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; use opentelemetry::global; @@ -234,6 +235,8 @@ fn build_test_processor( ) { let (outgoing_tx, outgoing_rx) = mpsc::channel(16); let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let auth_manager = + AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false); let processor = MessageProcessor::new(MessageProcessorArgs { outgoing, arg0_paths: Arg0DispatchPaths::default(), @@ -246,7 +249,7 @@ fn build_test_processor( log_db: None, config_warnings: Vec::new(), session_source: SessionSource::VSCode, - enable_codex_api_key_env: false, + auth_manager, rpc_transport: AppServerRpcTransport::Stdio, }); (processor, outgoing_rx) diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 17823fefd9..0fe835fc75 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -45,6 +45,7 @@ pub(crate) enum ThreadListenerCommand { /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { + pub(crate) started_at: Option, pub(crate) file_change_started: HashSet, pub(crate) command_execution_started: HashSet, pub(crate) last_error: Option, @@ -110,8 +111,13 @@ impl ThreadState { } pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { + if let EventMsg::TurnStarted(payload) = event { + self.turn_summary.started_at = payload.started_at; + } self.current_turn_history.handle_event(event); - if !self.current_turn_history.has_active_turn() { + if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)) + && !self.current_turn_history.has_active_turn() + { self.current_turn_history.reset(); } } diff --git a/codex-rs/app-server/src/transport/mod.rs b/codex-rs/app-server/src/transport/mod.rs index fa744a1af5..7e1512a794 100644 --- a/codex-rs/app-server/src/transport/mod.rs +++ b/codex-rs/app-server/src/transport/mod.rs @@ -17,6 +17,7 @@ use std::str::FromStr; use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -28,9 +29,11 @@ use tracing::warn; /// plenty for an interactive CLI. pub(crate) const CHANNEL_CAPACITY: usize = 128; +mod remote_control; mod stdio; mod websocket; +pub(crate) use remote_control::start_remote_control; pub(crate) use stdio::start_stdio_connection; pub(crate) use websocket::start_websocket_acceptor; @@ -38,6 +41,7 @@ pub(crate) use websocket::start_websocket_acceptor; pub enum AppServerTransport { Stdio, WebSocket { bind_address: SocketAddr }, + Off, } #[derive(Debug, Clone, Eq, PartialEq)] @@ -51,7 +55,7 @@ impl std::fmt::Display for AppServerTransportParseError { match self { AppServerTransportParseError::UnsupportedListenUrl(listen_url) => write!( f, - "unsupported --listen URL `{listen_url}`; expected `stdio://` or `ws://IP:PORT`" + "unsupported --listen URL `{listen_url}`; expected `stdio://`, `ws://IP:PORT`, or `off`" ), AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!( f, @@ -71,6 +75,10 @@ impl AppServerTransport { return Ok(Self::Stdio); } + if listen_url == "off" { + return Ok(Self::Off); + } + if let Some(socket_addr) = listen_url.strip_prefix("ws://") { let bind_address = socket_addr.parse::().map_err(|_| { AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string()) @@ -166,6 +174,12 @@ impl OutboundConnectionState { } } +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, writer: &mpsc::Sender, @@ -378,8 +392,11 @@ pub(crate) async fn route_outgoing_envelope( #[cfg(test)] mod tests { use super::*; - use crate::error_code::OVERLOADED_ERROR_CODE; 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 codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -393,41 +410,10 @@ mod tests { } #[test] - fn app_server_transport_parses_stdio_listen_url() { - let transport = AppServerTransport::from_listen_url(AppServerTransport::DEFAULT_LISTEN_URL) - .expect("stdio listen URL should parse"); - assert_eq!(transport, AppServerTransport::Stdio); - } - - #[test] - fn app_server_transport_parses_websocket_listen_url() { - let transport = AppServerTransport::from_listen_url("ws://127.0.0.1:1234") - .expect("websocket listen URL should parse"); + fn listen_off_parses_as_off_transport() { assert_eq!( - transport, - AppServerTransport::WebSocket { - bind_address: "127.0.0.1:1234".parse().expect("valid socket address"), - } - ); - } - - #[test] - fn app_server_transport_rejects_invalid_websocket_listen_url() { - let err = AppServerTransport::from_listen_url("ws://localhost:1234") - .expect_err("hostname bind address should be rejected"); - assert_eq!( - err.to_string(), - "invalid websocket --listen URL `ws://localhost:1234`; expected `ws://IP:PORT`" - ); - } - - #[test] - fn app_server_transport_rejects_unsupported_listen_url() { - let err = AppServerTransport::from_listen_url("http://127.0.0.1:1234") - .expect_err("unsupported scheme should fail"); - assert_eq!( - err.to_string(), - "unsupported --listen URL `http://127.0.0.1:1234`; expected `stdio://` or `ws://IP:PORT`" + AppServerTransport::from_listen_url("off"), + Ok(AppServerTransport::Off) ); } @@ -437,11 +423,10 @@ mod tests { 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(codex_app_server_protocol::JSONRPCNotification { - method: "initialized".to_string(), - params: None, - }); + let first_message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); transport_event_tx .send(TransportEvent::IncomingMessage { connection_id, @@ -450,8 +435,8 @@ mod tests { .await .expect("queue should accept first message"); - let request = JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { - id: codex_app_server_protocol::RequestId::Integer(7), + let request = JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(7), method: "config/read".to_string(), params: Some(json!({ "includeLayers": false })), trace: None, @@ -499,11 +484,10 @@ mod tests { let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); let (writer_tx, _writer_rx) = mpsc::channel(1); - let first_message = - JSONRPCMessage::Notification(codex_app_server_protocol::JSONRPCNotification { - method: "initialized".to_string(), - params: None, - }); + let first_message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); transport_event_tx .send(TransportEvent::IncomingMessage { connection_id, @@ -512,8 +496,8 @@ mod tests { .await .expect("queue should accept first message"); - let response = JSONRPCMessage::Response(codex_app_server_protocol::JSONRPCResponse { - id: codex_app_server_protocol::RequestId::Integer(7), + let response = JSONRPCMessage::Response(JSONRPCResponse { + id: RequestId::Integer(7), result: json!({"ok": true}), }); let transport_event_tx_for_enqueue = transport_event_tx.clone(); @@ -553,11 +537,10 @@ mod tests { match forwarded_event { TransportEvent::IncomingMessage { connection_id: queued_connection_id, - message: - JSONRPCMessage::Response(codex_app_server_protocol::JSONRPCResponse { id, result }), + message: JSONRPCMessage::Response(JSONRPCResponse { id, result }), } => { assert_eq!(queued_connection_id, connection_id); - assert_eq!(id, codex_app_server_protocol::RequestId::Integer(7)); + assert_eq!(id, RequestId::Integer(7)); assert_eq!(result, json!({"ok": true})); } _ => panic!("expected forwarded response message"), @@ -573,12 +556,10 @@ mod tests { transport_event_tx .send(TransportEvent::IncomingMessage { connection_id, - message: JSONRPCMessage::Notification( - codex_app_server_protocol::JSONRPCNotification { - method: "initialized".to_string(), - params: None, - }, - ), + message: JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }), }) .await .expect("transport queue should accept first message"); @@ -597,15 +578,15 @@ mod tests { .await .expect("writer queue should accept first message"); - let request = JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { - id: codex_app_server_protocol::RequestId::Integer(7), + let request = JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(7), method: "config/read".to_string(), params: Some(json!({ "includeLayers": false })), trace: None, }); - let enqueue_result = tokio::time::timeout( - std::time::Duration::from_millis(100), + let enqueue_result = timeout( + Duration::from_millis(100), enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request), ) .await @@ -781,7 +762,7 @@ mod tests { OutgoingEnvelope::ToConnection { connection_id, message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval { - request_id: codex_app_server_protocol::RequestId::Integer(1), + request_id: RequestId::Integer(1), params: codex_app_server_protocol::CommandExecutionRequestApprovalParams { thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), @@ -843,7 +824,7 @@ mod tests { OutgoingEnvelope::ToConnection { connection_id, message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval { - request_id: codex_app_server_protocol::RequestId::Integer(1), + request_id: RequestId::Integer(1), params: codex_app_server_protocol::CommandExecutionRequestApprovalParams { thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), diff --git a/codex-rs/app-server/src/transport/remote_control/client_tracker.rs b/codex-rs/app-server/src/transport/remote_control/client_tracker.rs new file mode 100644 index 0000000000..fa9a208ade --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/client_tracker.rs @@ -0,0 +1,568 @@ +use super::CHANNEL_CAPACITY; +use super::TransportEvent; +use super::next_connection_id; +use super::protocol::ClientEnvelope; +pub use super::protocol::ClientEvent; +pub use super::protocol::ClientId; +use super::protocol::PongStatus; +use super::protocol::ServerEvent; +use super::protocol::StreamId; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::QueuedOutgoingMessage; +use crate::transport::remote_control::QueuedServerEnvelope; +use codex_app_server_protocol::JSONRPCMessage; +use std::collections::HashMap; +use tokio::sync::mpsc; +use tokio::sync::watch; +use tokio::task::JoinSet; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +const REMOTE_CONTROL_CLIENT_IDLE_TIMEOUT: Duration = Duration::from_secs(10 * 60); +pub(crate) const REMOTE_CONTROL_IDLE_SWEEP_INTERVAL: Duration = Duration::from_secs(30); + +#[derive(Debug)] +pub(crate) struct Stopped; + +struct ClientState { + connection_id: ConnectionId, + disconnect_token: CancellationToken, + last_activity_at: Instant, + last_inbound_seq_id: Option, + status_tx: watch::Sender, +} + +pub(crate) struct ClientTracker { + clients: HashMap<(ClientId, StreamId), ClientState>, + legacy_stream_ids: HashMap, + join_set: JoinSet<(ClientId, StreamId)>, + server_event_tx: mpsc::Sender, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, +} + +impl ClientTracker { + pub(crate) fn new( + server_event_tx: mpsc::Sender, + transport_event_tx: mpsc::Sender, + shutdown_token: &CancellationToken, + ) -> Self { + Self { + clients: HashMap::new(), + legacy_stream_ids: HashMap::new(), + join_set: JoinSet::new(), + server_event_tx, + transport_event_tx, + shutdown_token: shutdown_token.child_token(), + } + } + + pub(crate) async fn bookkeep_join_set(&mut self) -> Option<(ClientId, StreamId)> { + while let Some(join_result) = self.join_set.join_next().await { + let Ok(client_key) = join_result else { + continue; + }; + return Some(client_key); + } + futures::future::pending().await + } + + pub(crate) async fn shutdown(&mut self) { + self.shutdown_token.cancel(); + + while let Some(client_key) = self.clients.keys().next().cloned() { + let _ = self.close_client(&client_key).await; + } + + self.drain_join_set().await; + } + + async fn drain_join_set(&mut self) { + while self.join_set.join_next().await.is_some() {} + } + + pub(crate) async fn handle_message( + &mut self, + client_envelope: ClientEnvelope, + ) -> Result<(), Stopped> { + let ClientEnvelope { + client_id, + event, + stream_id, + seq_id, + cursor: _, + } = client_envelope; + let is_legacy_stream_id = stream_id.is_none(); + let is_initialize = matches!(&event, ClientEvent::ClientMessage { message } if remote_control_message_starts_connection(message)); + let stream_id = match stream_id { + Some(stream_id) => stream_id, + None if is_initialize => { + // TODO(ruslan): delete this fallback once all clients are updated to send stream_id. + self.legacy_stream_ids + .remove(&client_id) + .unwrap_or_else(StreamId::new_random) + } + None => self + .legacy_stream_ids + .get(&client_id) + .cloned() + .unwrap_or_else(|| { + if matches!(&event, ClientEvent::Ping) { + StreamId::new_random() + } else { + StreamId(String::new()) + } + }), + }; + if stream_id.0.is_empty() { + return Ok(()); + } + let client_key = (client_id.clone(), stream_id.clone()); + match event { + ClientEvent::ClientMessage { message } => { + if let Some(seq_id) = seq_id + && let Some(client) = self.clients.get(&client_key) + && client + .last_inbound_seq_id + .is_some_and(|last_seq_id| last_seq_id >= seq_id) + && !is_initialize + { + return Ok(()); + } + + if is_initialize && self.clients.contains_key(&client_key) { + self.close_client(&client_key).await?; + } + + if let Some(connection_id) = self.clients.get_mut(&client_key).map(|client| { + client.last_activity_at = Instant::now(); + if let Some(seq_id) = seq_id { + client.last_inbound_seq_id = Some(seq_id); + } + client.connection_id + }) { + self.send_transport_event(TransportEvent::IncomingMessage { + connection_id, + message, + }) + .await?; + return Ok(()); + } + + if !is_initialize { + return Ok(()); + } + + let connection_id = next_connection_id(); + let (writer_tx, writer_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let disconnect_token = self.shutdown_token.child_token(); + self.send_transport_event(TransportEvent::ConnectionOpened { + connection_id, + writer: writer_tx, + disconnect_sender: Some(disconnect_token.clone()), + }) + .await?; + + let (status_tx, status_rx) = watch::channel(PongStatus::Active); + self.join_set.spawn(Self::run_client_outbound( + client_id.clone(), + stream_id.clone(), + self.server_event_tx.clone(), + writer_rx, + status_rx, + disconnect_token.clone(), + )); + self.clients.insert( + client_key, + ClientState { + connection_id, + disconnect_token, + last_activity_at: Instant::now(), + last_inbound_seq_id: if is_legacy_stream_id { None } else { seq_id }, + status_tx, + }, + ); + if is_legacy_stream_id { + self.legacy_stream_ids.insert(client_id.clone(), stream_id); + } + self.send_transport_event(TransportEvent::IncomingMessage { + connection_id, + message, + }) + .await + } + ClientEvent::Ack => Ok(()), + ClientEvent::Ping => { + if let Some(client) = self.clients.get_mut(&client_key) { + client.last_activity_at = Instant::now(); + let _ = client.status_tx.send(PongStatus::Active); + return Ok(()); + } + + let server_event_tx = self.server_event_tx.clone(); + tokio::spawn(async move { + let server_envelope = QueuedServerEnvelope { + event: ServerEvent::Pong { + status: PongStatus::Unknown, + }, + client_id, + stream_id, + write_complete_tx: None, + }; + let _ = server_event_tx.send(server_envelope).await; + }); + Ok(()) + } + ClientEvent::ClientClosed => self.close_client(&client_key).await, + } + } + + async fn run_client_outbound( + client_id: ClientId, + stream_id: StreamId, + server_event_tx: mpsc::Sender, + mut writer_rx: mpsc::Receiver, + mut status_rx: watch::Receiver, + disconnect_token: CancellationToken, + ) -> (ClientId, StreamId) { + loop { + let (event, write_complete_tx) = tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + queued_message = writer_rx.recv() => { + let Some(queued_message) = queued_message else { + break; + }; + let event = ServerEvent::ServerMessage { + message: Box::new(queued_message.message), + }; + (event, queued_message.write_complete_tx) + } + changed = status_rx.changed() => { + if changed.is_err() { + break; + } + let event = ServerEvent::Pong { status: status_rx.borrow().clone() }; + (event, None) + } + }; + let send_result = tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + send_result = server_event_tx.send(QueuedServerEnvelope { + event, + client_id: client_id.clone(), + stream_id: stream_id.clone(), + write_complete_tx, + }) => send_result, + }; + if send_result.is_err() { + break; + } + } + (client_id, stream_id) + } + + pub(crate) async fn close_expired_clients( + &mut self, + ) -> Result, Stopped> { + let now = Instant::now(); + let expired_client_ids: Vec<(ClientId, StreamId)> = self + .clients + .iter() + .filter_map(|(client_key, client)| { + (!remote_control_client_is_alive(client, now)).then_some(client_key.clone()) + }) + .collect(); + for client_key in &expired_client_ids { + self.close_client(client_key).await?; + } + Ok(expired_client_ids) + } + + pub(super) async fn close_client( + &mut self, + client_key: &(ClientId, StreamId), + ) -> Result<(), Stopped> { + let Some(client) = self.clients.remove(client_key) else { + return Ok(()); + }; + if self + .legacy_stream_ids + .get(&client_key.0) + .is_some_and(|stream_id| stream_id == &client_key.1) + { + self.legacy_stream_ids.remove(&client_key.0); + } + client.disconnect_token.cancel(); + self.send_transport_event(TransportEvent::ConnectionClosed { + connection_id: client.connection_id, + }) + .await + } + + async fn send_transport_event(&self, event: TransportEvent) -> Result<(), Stopped> { + self.transport_event_tx + .send(event) + .await + .map_err(|_| Stopped) + } +} + +fn remote_control_message_starts_connection(message: &JSONRPCMessage) -> bool { + matches!( + message, + JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { method, .. }) + if method == "initialize" + ) +} + +fn remote_control_client_is_alive(client: &ClientState, now: Instant) -> bool { + now.duration_since(client.last_activity_at) < REMOTE_CONTROL_CLIENT_IDLE_TIMEOUT +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outgoing_message::OutgoingMessage; + use crate::transport::remote_control::protocol::ClientEnvelope; + use crate::transport::remote_control::protocol::ClientEvent; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::JSONRPCRequest; + use codex_app_server_protocol::RequestId; + use codex_app_server_protocol::ServerNotification; + use pretty_assertions::assert_eq; + use serde_json::json; + use tokio::time::timeout; + + fn initialize_envelope(client_id: &str) -> ClientEnvelope { + initialize_envelope_with_stream_id(client_id, /*stream_id*/ None) + } + + fn initialize_envelope_with_stream_id( + client_id: &str, + stream_id: Option<&str>, + ) -> ClientEnvelope { + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(1), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }), + }, + client_id: ClientId(client_id.to_string()), + stream_id: stream_id.map(|stream_id| StreamId(stream_id.to_string())), + seq_id: Some(0), + cursor: None, + } + } + + #[tokio::test] + async fn cancelled_outbound_task_emits_connection_closed() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope("client-1")) + .await + .expect("initialize should open client"); + + let (connection_id, disconnect_sender) = match transport_event_rx + .recv() + .await + .expect("connection opened should be sent") + { + TransportEvent::ConnectionOpened { + connection_id, + disconnect_sender: Some(disconnect_sender), + .. + } => (connection_id, disconnect_sender), + other => panic!("expected connection opened, got {other:?}"), + }; + match transport_event_rx + .recv() + .await + .expect("initialize should be forwarded") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + .. + } => assert_eq!(incoming_connection_id, connection_id), + other => panic!("expected incoming initialize, got {other:?}"), + } + + disconnect_sender.cancel(); + let closed_client_id = timeout(Duration::from_secs(1), client_tracker.bookkeep_join_set()) + .await + .expect("bookkeeping should process the closed task") + .expect("closed task should return client id"); + assert_eq!(closed_client_id.0, ClientId("client-1".to_string())); + client_tracker + .close_client(&closed_client_id) + .await + .expect("closed client should emit connection closed"); + + match transport_event_rx + .recv() + .await + .expect("connection closed should be sent") + { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => assert_eq!(closed_connection_id, connection_id), + other => panic!("expected connection closed, got {other:?}"), + } + } + + #[tokio::test] + async fn shutdown_cancels_blocked_outbound_forwarding() { + let (server_event_tx, _server_event_rx) = mpsc::channel(1); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx.clone(), transport_event_tx, &shutdown_token); + + server_event_tx + .send(QueuedServerEnvelope { + event: ServerEvent::Pong { + status: PongStatus::Unknown, + }, + client_id: ClientId("queued-client".to_string()), + stream_id: StreamId("queued-stream".to_string()), + write_complete_tx: None, + }) + .await + .expect("server event queue should accept prefill"); + + client_tracker + .handle_message(initialize_envelope("client-1")) + .await + .expect("initialize should open client"); + + let writer = match transport_event_rx + .recv() + .await + .expect("connection opened should be sent") + { + TransportEvent::ConnectionOpened { writer, .. } => writer, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx + .recv() + .await + .expect("initialize should be forwarded"); + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "test".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("writer should accept queued message"); + + timeout(Duration::from_secs(1), client_tracker.shutdown()) + .await + .expect("shutdown should not hang on blocked server forwarding"); + } + + #[tokio::test] + async fn initialize_with_new_stream_id_opens_new_connection_for_same_client() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-1"), + )) + .await + .expect("first initialize should open client"); + let first_connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-2"), + )) + .await + .expect("second initialize should open client"); + let second_connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + + assert_ne!(first_connection_id, second_connection_id); + } + + #[tokio::test] + async fn legacy_initialize_without_stream_id_resets_inbound_seq_id() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope("client-1")) + .await + .expect("initialize should open client"); + let connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + client_tracker + .handle_message(ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Notification( + codex_app_server_protocol::JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }, + ), + }, + client_id: ClientId("client-1".to_string()), + stream_id: None, + seq_id: Some(0), + cursor: None, + }) + .await + .expect("legacy followup should be forwarded"); + + match transport_event_rx.recv().await.expect("followup event") { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + .. + } => assert_eq!(incoming_connection_id, connection_id), + other => panic!("expected incoming message, got {other:?}"), + } + } +} diff --git a/codex-rs/app-server/src/transport/remote_control/enroll.rs b/codex-rs/app-server/src/transport/remote_control/enroll.rs new file mode 100644 index 0000000000..dbe18c8355 --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/enroll.rs @@ -0,0 +1,503 @@ +use super::protocol::EnrollRemoteServerRequest; +use super::protocol::EnrollRemoteServerResponse; +use super::protocol::RemoteControlTarget; +use axum::http::HeaderMap; +use codex_login::default_client::build_reqwest_client; +use codex_state::RemoteControlEnrollmentRecord; +use codex_state::StateRuntime; +use gethostname::gethostname; +use std::io; +use std::io::ErrorKind; +use tracing::info; +use tracing::warn; + +const REMOTE_CONTROL_ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES: usize = 4096; + +const REQUEST_ID_HEADER: &str = "x-request-id"; +const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id"; +const CF_RAY_HEADER: &str = "cf-ray"; +pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RemoteControlEnrollment { + pub(super) account_id: String, + pub(super) environment_id: String, + pub(super) server_id: String, + pub(super) server_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RemoteControlConnectionAuth { + pub(super) bearer_token: String, + pub(super) account_id: String, +} + +pub(super) async fn load_persisted_remote_control_enrollment( + state_db: Option<&StateRuntime>, + remote_control_target: &RemoteControlTarget, + account_id: &str, + app_server_client_name: Option<&str>, +) -> Option { + let Some(state_db) = state_db else { + info!( + "remote control enrollment cache unavailable because sqlite state db is disabled: websocket_url={}, account_id={}, app_server_client_name={:?}", + remote_control_target.websocket_url, account_id, app_server_client_name + ); + return None; + }; + let enrollment = match state_db + .get_remote_control_enrollment( + &remote_control_target.websocket_url, + account_id, + app_server_client_name, + ) + .await + { + Ok(enrollment) => enrollment, + Err(err) => { + warn!( + "failed to load persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, err={err}", + remote_control_target.websocket_url, account_id, app_server_client_name + ); + return None; + } + }; + + match enrollment { + Some(enrollment) => { + info!( + "reusing persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + account_id, + app_server_client_name, + enrollment.server_id, + enrollment.environment_id + ); + Some(RemoteControlEnrollment { + account_id: enrollment.account_id, + environment_id: enrollment.environment_id, + server_id: enrollment.server_id, + server_name: enrollment.server_name, + }) + } + None => { + info!( + "no persisted remote control enrollment found: websocket_url={}, account_id={}, app_server_client_name={:?}", + remote_control_target.websocket_url, account_id, app_server_client_name + ); + None + } + } +} + +pub(super) async fn update_persisted_remote_control_enrollment( + state_db: Option<&StateRuntime>, + remote_control_target: &RemoteControlTarget, + account_id: &str, + app_server_client_name: Option<&str>, + enrollment: Option<&RemoteControlEnrollment>, +) -> io::Result<()> { + let Some(state_db) = state_db else { + info!( + "skipping remote control enrollment persistence because sqlite state db is disabled: websocket_url={}, account_id={}, app_server_client_name={:?}, has_enrollment={}", + remote_control_target.websocket_url, + account_id, + app_server_client_name, + enrollment.is_some() + ); + return Ok(()); + }; + if let &Some(enrollment) = &enrollment + && enrollment.account_id != account_id + { + return Err(io::Error::other(format!( + "enrollment account_id does not match expected account_id `{account_id}`" + ))); + } + + if let Some(enrollment) = enrollment { + state_db + .upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord { + websocket_url: remote_control_target.websocket_url.clone(), + account_id: account_id.to_string(), + app_server_client_name: app_server_client_name.map(str::to_string), + server_id: enrollment.server_id.clone(), + environment_id: enrollment.environment_id.clone(), + server_name: enrollment.server_name.clone(), + }) + .await + .map_err(io::Error::other)?; + info!( + "persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + account_id, + app_server_client_name, + enrollment.server_id, + enrollment.environment_id + ); + Ok(()) + } else { + let rows_affected = state_db + .delete_remote_control_enrollment( + &remote_control_target.websocket_url, + account_id, + app_server_client_name, + ) + .await + .map_err(io::Error::other)?; + info!( + "cleared persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, rows_affected={rows_affected}", + remote_control_target.websocket_url, account_id, app_server_client_name + ); + Ok(()) + } +} + +pub(crate) fn preview_remote_control_response_body(body: &[u8]) -> String { + let body = String::from_utf8_lossy(body); + let trimmed = body.trim(); + if trimmed.is_empty() { + return "".to_string(); + } + if trimmed.len() <= REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES { + return trimmed.to_string(); + } + + let mut cut = REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES; + while !trimmed.is_char_boundary(cut) { + cut = cut.saturating_sub(1); + } + let mut truncated = trimmed[..cut].to_string(); + truncated.push_str("..."); + truncated +} + +pub(crate) fn format_headers(headers: &HeaderMap) -> String { + let request_id_str = headers + .get(REQUEST_ID_HEADER) + .or_else(|| headers.get(OAI_REQUEST_ID_HEADER)) + .map(|value| value.to_str().unwrap_or("").to_owned()) + .unwrap_or_else(|| "".to_owned()); + let cf_ray_str = headers + .get(CF_RAY_HEADER) + .map(|value| value.to_str().unwrap_or("").to_owned()) + .unwrap_or_else(|| "".to_owned()); + format!("request-id: {request_id_str}, cf-ray: {cf_ray_str}") +} + +pub(super) async fn enroll_remote_control_server( + remote_control_target: &RemoteControlTarget, + auth: &RemoteControlConnectionAuth, +) -> io::Result { + let enroll_url = &remote_control_target.enroll_url; + let server_name = gethostname().to_string_lossy().trim().to_string(); + let request = EnrollRemoteServerRequest { + name: server_name.clone(), + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + app_server_version: env!("CARGO_PKG_VERSION"), + }; + let client = build_reqwest_client(); + let http_request = client + .post(enroll_url) + .timeout(REMOTE_CONTROL_ENROLL_TIMEOUT) + .bearer_auth(&auth.bearer_token) + .header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id) + .json(&request); + + let response = http_request.send().await.map_err(|err| { + io::Error::other(format!( + "failed to enroll remote control server at `{enroll_url}`: {err}" + )) + })?; + let headers = response.headers().clone(); + let status = response.status(); + let body = response.bytes().await.map_err(|err| { + io::Error::other(format!( + "failed to read remote control enrollment response from `{enroll_url}`: {err}" + )) + })?; + let body_preview = preview_remote_control_response_body(&body); + if !status.is_success() { + let headers_str = format_headers(&headers); + let error_kind = if matches!(status.as_u16(), 401 | 403) { + ErrorKind::PermissionDenied + } else { + ErrorKind::Other + }; + return Err(io::Error::new( + error_kind, + format!( + "remote control server enrollment failed at `{enroll_url}`: HTTP {status}, {headers_str}, body: {body_preview}" + ), + )); + } + + let enrollment = serde_json::from_slice::(&body).map_err(|err| { + let headers_str = format_headers(&headers); + io::Error::other(format!( + "failed to parse remote control enrollment response from `{enroll_url}`: HTTP {status}, {headers_str}, body: {body_preview}, decode error: {err}" + )) + })?; + + Ok(RemoteControlEnrollment { + account_id: auth.account_id.clone(), + environment_id: enrollment.environment_id, + server_id: enrollment.server_id, + server_name, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::remote_control::protocol::normalize_remote_control_url; + use codex_state::StateRuntime; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Arc; + use tempfile::TempDir; + use tokio::io::AsyncBufReadExt; + use tokio::io::AsyncWriteExt; + use tokio::io::BufReader; + use tokio::net::TcpListener; + use tokio::net::TcpStream; + use tokio::time::Duration; + use tokio::time::timeout; + + async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) + .await + .expect("state runtime should initialize") + } + + #[tokio::test] + async fn persisted_remote_control_enrollment_round_trips_by_target_and_account() { + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let first_target = normalize_remote_control_url("https://chatgpt.com/remote/control") + .expect("first target should parse"); + let second_target = + normalize_remote_control_url("https://api.chatgpt-staging.com/other/control") + .expect("second target should parse"); + let first_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_first".to_string(), + server_id: "srv_e_first".to_string(), + server_name: "first-server".to_string(), + }; + let second_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_second".to_string(), + server_id: "srv_e_second".to_string(), + server_name: "second-server".to_string(), + }; + + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + Some("desktop-client"), + Some(&first_enrollment), + ) + .await + .expect("first enrollment should persist"); + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + Some("desktop-client"), + Some(&second_enrollment), + ) + .await + .expect("second enrollment should persist"); + + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + Some("desktop-client"), + ) + .await, + Some(first_enrollment.clone()) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-b", + Some("desktop-client"), + ) + .await, + None + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + Some("desktop-client"), + ) + .await, + Some(second_enrollment) + ); + } + + #[tokio::test] + async fn clearing_persisted_remote_control_enrollment_removes_only_matching_entry() { + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let first_target = normalize_remote_control_url("https://chatgpt.com/remote/control") + .expect("first target should parse"); + let second_target = + normalize_remote_control_url("https://api.chatgpt-staging.com/other/control") + .expect("second target should parse"); + let first_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_first".to_string(), + server_id: "srv_e_first".to_string(), + server_name: "first-server".to_string(), + }; + let second_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_second".to_string(), + server_id: "srv_e_second".to_string(), + server_name: "second-server".to_string(), + }; + + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + /*app_server_client_name*/ None, + Some(&first_enrollment), + ) + .await + .expect("first enrollment should persist"); + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + /*app_server_client_name*/ None, + Some(&second_enrollment), + ) + .await + .expect("second enrollment should persist"); + + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + /*app_server_client_name*/ None, + /*enrollment*/ None, + ) + .await + .expect("matching enrollment should clear"); + + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + /*app_server_client_name*/ None, + ) + .await, + None + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + /*app_server_client_name*/ None, + ) + .await, + Some(second_enrollment) + ); + } + + #[tokio::test] + async fn enroll_remote_control_server_parse_failure_includes_response_body() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = format!( + "http://127.0.0.1:{}/backend-api/", + listener + .local_addr() + .expect("listener should have a local addr") + .port() + ); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let enroll_url = remote_control_target.enroll_url.clone(); + let response_body = json!({ + "error": "not enrolled", + }); + let expected_body = response_body.to_string(); + let server_task = tokio::spawn(async move { + let stream = accept_http_request(&listener).await; + respond_with_json(stream, response_body).await; + }); + + let err = enroll_remote_control_server( + &remote_control_target, + &RemoteControlConnectionAuth { + bearer_token: "Access Token".to_string(), + account_id: "account_id".to_string(), + }, + ) + .await + .expect_err("invalid response should fail to parse"); + + server_task.await.expect("server task should succeed"); + assert_eq!( + err.to_string(), + format!( + "failed to parse remote control enrollment response from `{enroll_url}`: HTTP 200 OK, request-id: , cf-ray: , body: {expected_body}, decode error: missing field `server_id` at line 1 column {}", + expected_body.len() + ) + ); + } + + async fn accept_http_request(listener: &TcpListener) -> TcpStream { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("HTTP request should arrive in time") + .expect("listener accept should succeed"); + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("request line should read"); + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .expect("header line should read"); + if line == "\r\n" { + break; + } + } + + reader.into_inner() + } + + async fn respond_with_json(mut stream: TcpStream, body: serde_json::Value) { + let body = body.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); + } +} diff --git a/codex-rs/app-server/src/transport/remote_control/mod.rs b/codex-rs/app-server/src/transport/remote_control/mod.rs new file mode 100644 index 0000000000..6d9d65e8a3 --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/mod.rs @@ -0,0 +1,67 @@ +mod client_tracker; +mod enroll; +mod protocol; +mod websocket; + +use crate::transport::remote_control::websocket::RemoteControlWebsocket; +use crate::transport::remote_control::websocket::load_remote_control_auth; + +pub use self::protocol::ClientId; +use self::protocol::ServerEvent; +use self::protocol::StreamId; +use self::protocol::normalize_remote_control_url; +use super::CHANNEL_CAPACITY; +use super::TransportEvent; +use super::next_connection_id; +use codex_login::AuthManager; +use codex_state::StateRuntime; +use std::io; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +pub(super) struct QueuedServerEnvelope { + pub(super) event: ServerEvent, + pub(super) client_id: ClientId, + pub(super) stream_id: StreamId, + pub(super) write_complete_tx: Option>, +} + +pub(crate) async fn start_remote_control( + remote_control_url: String, + state_db: Option>, + auth_manager: Arc, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, + app_server_client_name_rx: Option>, +) -> io::Result> { + let remote_control_target = normalize_remote_control_url(&remote_control_url)?; + validate_remote_control_auth(&auth_manager).await?; + + Ok(tokio::spawn(async move { + RemoteControlWebsocket::new( + remote_control_target, + state_db, + auth_manager, + transport_event_tx, + shutdown_token, + ) + .run(app_server_client_name_rx) + .await; + })) +} + +pub(crate) async fn validate_remote_control_auth( + auth_manager: &Arc, +) -> io::Result<()> { + match load_remote_control_auth(auth_manager).await { + Ok(_) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::WouldBlock => Ok(()), + Err(err) => Err(err), + } +} + +#[cfg(test)] +mod tests; diff --git a/codex-rs/app-server/src/transport/remote_control/protocol.rs b/codex-rs/app-server/src/transport/remote_control/protocol.rs new file mode 100644 index 0000000000..857855f2a0 --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/protocol.rs @@ -0,0 +1,252 @@ +use crate::outgoing_message::OutgoingMessage; +use codex_app_server_protocol::JSONRPCMessage; +use serde::Deserialize; +use serde::Serialize; +use std::io; +use std::io::ErrorKind; +use url::Host; +use url::Url; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RemoteControlTarget { + pub(super) websocket_url: String, + pub(super) enroll_url: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct EnrollRemoteServerRequest { + pub(super) name: String, + pub(super) os: &'static str, + pub(super) arch: &'static str, + pub(super) app_server_version: &'static str, +} + +#[derive(Debug, Deserialize)] +pub(super) struct EnrollRemoteServerResponse { + pub(super) server_id: String, + pub(super) environment_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ClientId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StreamId(pub String); + +impl StreamId { + pub fn new_random() -> Self { + Self(uuid::Uuid::now_v7().to_string()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientEvent { + ClientMessage { + message: JSONRPCMessage, + }, + /// Backend-generated acknowledgement for all server envelopes addressed to + /// `client_id` whose envelope `seq_id` is less than or equal to this ack's + /// `seq_id`. This cursor is client-scoped, not stream-scoped, so receivers + /// must not use `stream_id` to partition acks. + Ack, + Ping, + ClientClosed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) struct ClientEnvelope { + #[serde(flatten)] + pub(crate) event: ClientEvent, + #[serde(rename = "client_id")] + pub(crate) client_id: ClientId, + #[serde(rename = "stream_id", skip_serializing_if = "Option::is_none")] + pub(crate) stream_id: Option, + /// For `Ack`, this is the backend-generated per-client cursor over + /// `ServerEnvelope.seq_id`. + #[serde(rename = "seq_id", skip_serializing_if = "Option::is_none")] + pub(crate) seq_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PongStatus { + Active, + Unknown, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerEvent { + ServerMessage { + message: Box, + }, + #[allow(dead_code)] + Ack, + Pong { + status: PongStatus, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) struct ServerEnvelope { + #[serde(flatten)] + pub(crate) event: ServerEvent, + #[serde(rename = "client_id")] + pub(crate) client_id: ClientId, + #[serde(rename = "stream_id")] + pub(crate) stream_id: StreamId, + #[serde(rename = "seq_id")] + pub(crate) seq_id: u64, +} + +fn is_allowed_chatgpt_host(host: &Option>) -> bool { + let Some(Host::Domain(host)) = *host else { + return false; + }; + host == "chatgpt.com" + || host == "chatgpt-staging.com" + || host.ends_with(".chatgpt.com") + || host.ends_with(".chatgpt-staging.com") +} + +fn is_localhost(host: &Option>) -> bool { + match host { + Some(Host::Domain("localhost")) => true, + Some(Host::Ipv4(ip)) => ip.is_loopback(), + Some(Host::Ipv6(ip)) => ip.is_loopback(), + _ => false, + } +} + +pub(super) fn normalize_remote_control_url( + remote_control_url: &str, +) -> io::Result { + let map_url_parse_error = |err: url::ParseError| -> io::Error { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control URL `{remote_control_url}`: {err}"), + ) + }; + let map_scheme_error = |_: ()| -> io::Error { + io::Error::new( + ErrorKind::InvalidInput, + format!( + "invalid remote control URL `{remote_control_url}`; expected HTTPS URL for chatgpt.com or chatgpt-staging.com, or HTTP/HTTPS URL for localhost" + ), + ) + }; + + let mut remote_control_url = Url::parse(remote_control_url).map_err(map_url_parse_error)?; + if !remote_control_url.path().ends_with('/') { + let normalized_path = format!("{}/", remote_control_url.path()); + remote_control_url.set_path(&normalized_path); + } + + let enroll_url = remote_control_url + .join("wham/remote/control/server/enroll") + .map_err(map_url_parse_error)?; + let mut websocket_url = remote_control_url + .join("wham/remote/control/server") + .map_err(map_url_parse_error)?; + let host = enroll_url.host(); + match enroll_url.scheme() { + "https" if is_localhost(&host) || is_allowed_chatgpt_host(&host) => { + websocket_url.set_scheme("wss").map_err(map_scheme_error)?; + } + "http" if is_localhost(&host) => { + websocket_url.set_scheme("ws").map_err(map_scheme_error)?; + } + _ => return Err(map_scheme_error(())), + } + + Ok(RemoteControlTarget { + websocket_url: websocket_url.to_string(), + enroll_url: enroll_url.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn normalize_remote_control_url_accepts_chatgpt_https_urls() { + assert_eq!( + normalize_remote_control_url("https://chatgpt.com/backend-api") + .expect("chatgpt.com URL should normalize"), + RemoteControlTarget { + websocket_url: "wss://chatgpt.com/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: "https://chatgpt.com/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + assert_eq!( + normalize_remote_control_url("https://api.chatgpt-staging.com/backend-api") + .expect("chatgpt-staging.com subdomain URL should normalize"), + RemoteControlTarget { + websocket_url: + "wss://api.chatgpt-staging.com/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: + "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + } + + #[test] + fn normalize_remote_control_url_accepts_localhost_urls() { + assert_eq!( + normalize_remote_control_url("http://localhost:8080/backend-api") + .expect("localhost http URL should normalize"), + RemoteControlTarget { + websocket_url: "ws://localhost:8080/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: "http://localhost:8080/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + assert_eq!( + normalize_remote_control_url("https://localhost:8443/backend-api") + .expect("localhost https URL should normalize"), + RemoteControlTarget { + websocket_url: "wss://localhost:8443/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: "https://localhost:8443/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + } + + #[test] + fn normalize_remote_control_url_rejects_unsupported_urls() { + for remote_control_url in [ + "http://chatgpt.com/backend-api", + "http://example.com/backend-api", + "https://example.com/backend-api", + "https://chatgpt.com.evil.com/backend-api", + "https://evilchatgpt.com/backend-api", + "https://foo.localhost/backend-api", + ] { + let err = normalize_remote_control_url(remote_control_url) + .expect_err("unsupported URL should be rejected"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + format!( + "invalid remote control URL `{remote_control_url}`; expected HTTPS URL for chatgpt.com or chatgpt-staging.com, or HTTP/HTTPS URL for localhost" + ) + ); + } + } +} diff --git a/codex-rs/app-server/src/transport/remote_control/tests.rs b/codex-rs/app-server/src/transport/remote_control/tests.rs new file mode 100644 index 0000000000..b403fac484 --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/tests.rs @@ -0,0 +1,1310 @@ +use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER; +use super::enroll::RemoteControlEnrollment; +use super::enroll::load_persisted_remote_control_enrollment; +use super::enroll::update_persisted_remote_control_enrollment; +use super::protocol::ClientEnvelope; +use super::protocol::ClientEvent; +use super::protocol::ClientId; +use super::protocol::normalize_remote_control_url; +use super::websocket::REMOTE_CONTROL_PROTOCOL_VERSION; +use super::*; +use crate::outgoing_message::OutgoingMessage; +use crate::outgoing_message::QueuedOutgoingMessage; +use crate::transport::CHANNEL_CAPACITY; +use crate::transport::TransportEvent; +use base64::Engine; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::ServerNotification; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::test_support::auth_manager_from_auth; +use codex_core::test_support::auth_manager_from_auth_with_home; +use codex_login::AuthDotJson; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::save_auth; +use codex_login::token_data::TokenData; +use codex_login::token_data::parse_chatgpt_jwt_claims; +use codex_state::StateRuntime; +use futures::SinkExt; +use futures::StreamExt; +use gethostname::gethostname; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::Duration; +use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::accept_hdr_async; +use tokio_tungstenite::tungstenite; +use tokio_util::sync::CancellationToken; + +fn remote_control_auth_manager() -> Arc { + auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) +} + +fn remote_control_auth_manager_with_home(codex_home: &TempDir) -> Arc { + auth_manager_from_auth_with_home( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + codex_home.path().to_path_buf(), + ) +} + +fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson { + #[derive(serde::Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + "chatgpt_account_id": "account_id" + } + }); + let b64 = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = b64(&serde_json::to_vec(&header).expect("header should serialize")); + let payload_b64 = b64(&serde_json::to_vec(&payload).expect("payload should serialize")); + let fake_jwt = format!("{header_b64}.{payload_b64}.sig"); + + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: parse_chatgpt_jwt_claims(&fake_jwt).expect("fake jwt should parse"), + access_token: "Access Token".to_string(), + refresh_token: "refresh-token".to_string(), + account_id: account_id.map(str::to_string), + }), + last_refresh: Some(chrono::Utc::now()), + } +} + +async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) + .await + .expect("state runtime should initialize") +} + +fn remote_control_url_for_listener(listener: &TcpListener) -> String { + format!( + "http://localhost:{}/backend-api/", + listener + .local_addr() + .expect("listener should have a local addr") + .port() + ) +} + +#[tokio::test] +async fn remote_control_transport_manages_virtual_clients_and_routes_messages() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let remote_handle = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + ) + .await + .expect("remote control should start"); + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + let mut websocket = accept_remote_control_connection(&listener).await; + + let client_id = ClientId("client-1".to_string()); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 0, + "status": "unknown", + }) + ); + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Notification( + codex_app_server_protocol::JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }, + ), + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + assert!( + timeout(Duration::from_millis(100), transport_event_rx.recv()) + .await + .is_err(), + "non-initialize client messages should be ignored before connection creation" + ); + + let initialize_message = JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(1), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: initialize_message.clone(), + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(1), + cursor: None, + }, + ) + .await; + + let (connection_id, writer) = match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection open should arrive in time") + .expect("connection open should exist") + { + TransportEvent::ConnectionOpened { + connection_id, + writer, + .. + } => (connection_id, writer), + other => panic!("expected connection open event, got {other:?}"), + }; + + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("initialize message should arrive in time") + .expect("initialize message should exist") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + message, + } => { + assert_eq!(incoming_connection_id, connection_id); + assert_eq!(message, initialize_message); + } + other => panic!("expected initialize incoming message, got {other:?}"), + } + + let followup_message = + JSONRPCMessage::Notification(codex_app_server_protocol::JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: followup_message.clone(), + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(2), + cursor: None, + }, + ) + .await; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("followup message should arrive in time") + .expect("followup message should exist") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + message, + } => { + assert_eq!(incoming_connection_id, connection_id); + assert_eq!(message, followup_message); + } + other => panic!("expected followup incoming message, got {other:?}"), + } + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 1, + "status": "active", + }) + ); + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "test".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("remote writer should accept outgoing message"); + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "server_message", + "client_id": "client-1", + "seq_id": 2, + "message": { + "method": "configWarning", + "params": { + "summary": "test", + "details": null, + } + } + }) + ); + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientClosed, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection close should arrive in time") + .expect("connection close should exist") + { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => { + assert_eq!(closed_connection_id, connection_id); + } + other => panic!("expected connection close event, got {other:?}"), + } + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id, + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 3, + "status": "unknown", + }) + ); + + shutdown_token.cancel(); + let _ = remote_handle.await; +} + +#[tokio::test] +async fn remote_control_transport_reconnects_after_disconnect() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let remote_handle = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + ) + .await + .expect("remote control should start"); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + let mut first_websocket = accept_remote_control_connection(&listener).await; + first_websocket + .close(None) + .await + .expect("first websocket should close"); + drop(first_websocket); + + let mut second_websocket = accept_remote_control_connection(&listener).await; + send_client_event( + &mut second_websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(2), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }), + }, + client_id: ClientId("client-2".to_string()), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("reconnected initialize should arrive in time") + .expect("reconnected initialize should exist") + { + TransportEvent::ConnectionOpened { .. } => {} + other => panic!("expected connection open after reconnect, got {other:?}"), + } + + shutdown_token.cancel(); + let _ = remote_handle.await; +} + +#[tokio::test] +async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let remote_handle = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + ) + .await + .expect("remote control should start"); + + let enroll_request = accept_http_request(&listener).await; + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + let mut first_websocket = accept_remote_control_connection(&listener).await; + + let client_id = ClientId("client-1".to_string()); + let initialize_message = JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(1), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }); + send_client_event( + &mut first_websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: initialize_message, + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + + let writer = match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection open should arrive in time") + .expect("connection open should exist") + { + TransportEvent::ConnectionOpened { writer, .. } => writer, + other => panic!("expected connection open event, got {other:?}"), + }; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("initialize message should arrive in time") + .expect("initialize message should exist") + { + TransportEvent::IncomingMessage { .. } => {} + other => panic!("expected initialize incoming message, got {other:?}"), + } + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "stale".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("remote writer should accept outgoing message"); + assert_eq!( + read_server_event(&mut first_websocket).await, + json!({ + "type": "server_message", + "client_id": "client-1", + "seq_id": 0, + "message": { + "method": "configWarning", + "params": { + "summary": "stale", + "details": null, + } + } + }) + ); + + send_client_event( + &mut first_websocket, + ClientEnvelope { + event: ClientEvent::Ack, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + + send_client_event( + &mut first_websocket, + ClientEnvelope { + event: ClientEvent::ClientClosed, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection close should arrive in time") + .expect("connection close should exist") + { + TransportEvent::ConnectionClosed { .. } => {} + other => panic!("expected connection close event, got {other:?}"), + } + + first_websocket + .close(None) + .await + .expect("first websocket should close"); + drop(first_websocket); + + let mut second_websocket = accept_remote_control_connection(&listener).await; + send_client_event( + &mut second_websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id, + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut second_websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 1, + "status": "unknown", + }) + ); + + shutdown_token.cancel(); + let _ = remote_handle.await; +} + +#[tokio::test] +async fn remote_control_http_mode_enrolls_before_connecting() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let shutdown_token = CancellationToken::new(); + let remote_handle = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + ) + .await + .expect("remote control should start"); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + assert_eq!( + enroll_request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + assert_eq!( + enroll_request.headers.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), + Some(&"account_id".to_string()) + ); + assert_eq!( + serde_json::from_str::(&enroll_request.body) + .expect("enroll body should deserialize"), + json!({ + "name": expected_server_name, + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "app_server_version": env!("CARGO_PKG_VERSION"), + }) + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + + let (handshake_request, mut websocket) = + accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.path, + "/backend-api/wham/remote/control/server" + ); + assert_eq!( + handshake_request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + assert_eq!( + handshake_request + .headers + .get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), + Some(&"account_id".to_string()) + ); + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&"srv_e_test".to_string()) + ); + assert_eq!( + handshake_request.headers.get("x-codex-name"), + Some(&base64::engine::general_purpose::STANDARD.encode(&expected_server_name)) + ); + assert_eq!( + handshake_request.headers.get("x-codex-protocol-version"), + Some(&REMOTE_CONTROL_PROTOCOL_VERSION.to_string()) + ); + + let backend_client_id = ClientId("backend-test-client".to_string()); + let writer = { + let initialize_message = + JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(11), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-backend-client", + "version": "0.1.0" + } + })), + trace: None, + }); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: initialize_message.clone(), + }, + client_id: backend_client_id.clone(), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + + let (connection_id, writer) = + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection open should arrive in time") + .expect("connection open should exist") + { + TransportEvent::ConnectionOpened { + connection_id, + writer, + .. + } => (connection_id, writer), + other => panic!("expected connection open event, got {other:?}"), + }; + + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("initialize message should arrive in time") + .expect("initialize message should exist") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + message, + } => { + assert_eq!(incoming_connection_id, connection_id); + assert_eq!(message, initialize_message); + } + other => panic!("expected initialize incoming message, got {other:?}"), + } + writer + }; + + writer + .send(QueuedOutgoingMessage::new(OutgoingMessage::Response( + crate::outgoing_message::OutgoingResponse { + id: codex_app_server_protocol::RequestId::Integer(11), + result: json!({ + "userAgent": "codex-test-agent" + }), + }, + ))) + .await + .expect("remote writer should accept initialize response"); + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "server_message", + "client_id": backend_client_id.0.clone(), + "seq_id": 0, + "message": { + "id": 11, + "result": { + "userAgent": "codex-test-agent", + } + } + }) + ); + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "backend".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("remote writer should accept outgoing message"); + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "server_message", + "client_id": backend_client_id.0.clone(), + "seq_id": 1, + "message": { + "method": "configWarning", + "params": { + "summary": "backend", + "details": null, + } + } + }) + ); + + shutdown_token.cancel(); + let _ = remote_handle.await; +} + +#[tokio::test] +async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let persisted_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_persisted".to_string(), + server_id: "srv_e_persisted".to_string(), + server_name: "persisted-server".to_string(), + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&persisted_enrollment), + ) + .await + .expect("persisted enrollment should save"); + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let remote_handle = start_remote_control( + remote_control_url, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + ) + .await + .expect("remote control should start"); + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.path, + "/backend-api/wham/remote/control/server" + ); + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&persisted_enrollment.server_id) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await, + Some(persisted_enrollment) + ); + + shutdown_token.cancel(); + let _ = remote_handle.await; +} + +#[tokio::test] +async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let app_server_client_name = "stdio-client"; + let persisted_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_persisted".to_string(), + server_id: "srv_e_persisted".to_string(), + server_name: "persisted-server".to_string(), + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + Some(app_server_client_name), + Some(&persisted_enrollment), + ) + .await + .expect("persisted enrollment should save"); + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let (app_server_client_name_tx, app_server_client_name_rx) = oneshot::channel::(); + let shutdown_token = CancellationToken::new(); + let remote_handle = start_remote_control( + remote_control_url, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + Some(app_server_client_name_rx), + ) + .await + .expect("remote control should start"); + + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("remote control should wait for the stdio client name"); + + let _ = app_server_client_name_tx.send(app_server_client_name.to_string()); + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&persisted_enrollment.server_id) + ); + + shutdown_token.cancel(); + let _ = remote_handle.await; +} + +#[tokio::test] +async fn remote_control_waits_for_account_id_before_enrolling() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json(/*account_id*/ None), + AuthCredentialsStoreMode::File, + ) + .expect("auth without account id should save"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + ); + let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let expected_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_ready".to_string(), + server_id: "srv_e_ready".to_string(), + server_name: expected_server_name, + }; + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let remote_handle = start_remote_control( + remote_control_url, + Some(state_db.clone()), + auth_manager, + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + ) + .await + .expect("remote control should start before account id is available"); + + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("remote control should wait for account id before enrolling"); + + save_auth( + codex_home.path(), + &remote_control_auth_dot_json(Some("account_id")), + AuthCredentialsStoreMode::File, + ) + .expect("auth with account id should save"); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ + "server_id": expected_enrollment.server_id, + "environment_id": expected_enrollment.environment_id, + }), + ) + .await; + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&expected_enrollment.server_id) + ); + + shutdown_token.cancel(); + let _ = remote_handle.await; +} + +#[tokio::test] +async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let stale_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_stale".to_string(), + server_id: "srv_e_stale".to_string(), + server_name: "stale-server".to_string(), + }; + let refreshed_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_refreshed".to_string(), + server_id: "srv_e_refreshed".to_string(), + server_name: expected_server_name, + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&stale_enrollment), + ) + .await + .expect("stale enrollment should save"); + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let remote_handle = start_remote_control( + remote_control_url, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + ) + .await + .expect("remote control should start"); + + let websocket_request = accept_http_request(&listener).await; + assert_eq!( + websocket_request.request_line, + "GET /backend-api/wham/remote/control/server HTTP/1.1" + ); + assert_eq!( + websocket_request.headers.get("x-codex-server-id"), + Some(&stale_enrollment.server_id) + ); + respond_with_status(websocket_request.stream, "404 Not Found", "").await; + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ + "server_id": refreshed_enrollment.server_id, + "environment_id": refreshed_enrollment.environment_id, + }), + ) + .await; + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&refreshed_enrollment.server_id) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await, + Some(refreshed_enrollment) + ); + + shutdown_token.cancel(); + let _ = remote_handle.await; +} + +#[derive(Debug)] +struct CapturedHttpRequest { + stream: TcpStream, + request_line: String, + headers: BTreeMap, + body: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CapturedWebSocketRequest { + path: String, + headers: BTreeMap, +} + +async fn accept_remote_control_connection(listener: &TcpListener) -> WebSocketStream { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("remote control should connect in time") + .expect("listener accept should succeed"); + accept_async(stream) + .await + .expect("websocket handshake should succeed") +} + +async fn accept_http_request(listener: &TcpListener) -> CapturedHttpRequest { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("HTTP request should arrive in time") + .expect("listener accept should succeed"); + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("request line should read"); + let request_line = request_line.trim_end_matches("\r\n").to_string(); + + let mut headers = BTreeMap::new(); + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .expect("header line should read"); + if line == "\r\n" { + break; + } + let line = line.trim_end_matches("\r\n"); + let (name, value) = line.split_once(':').expect("header should contain colon"); + headers.insert(name.to_ascii_lowercase(), value.trim().to_string()); + } + + let content_length = headers + .get("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let mut body = vec![0; content_length]; + reader + .read_exact(&mut body) + .await + .expect("request body should read"); + + CapturedHttpRequest { + stream: reader.into_inner(), + request_line, + headers, + body: String::from_utf8(body).expect("body should be utf-8"), + } +} + +async fn respond_with_json(mut stream: TcpStream, body: serde_json::Value) { + let body = body.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); +} + +async fn respond_with_status(stream: TcpStream, status: &str, body: &str) { + respond_with_status_and_headers(stream, status, &[], body).await; +} + +async fn respond_with_status_and_headers( + mut stream: TcpStream, + status: &str, + headers: &[(&str, &str)], + body: &str, +) { + let extra_headers = headers + .iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n{extra_headers}\r\n{body}", + body.len(), + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); +} + +async fn accept_remote_control_backend_connection( + listener: &TcpListener, +) -> (CapturedWebSocketRequest, WebSocketStream) { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("websocket request should arrive in time") + .expect("listener accept should succeed"); + let captured_request = Arc::new(std::sync::Mutex::new(None::)); + let captured_request_for_callback = captured_request.clone(); + let websocket = accept_hdr_async( + stream, + move |request: &tungstenite::handshake::server::Request, + response: tungstenite::handshake::server::Response| { + let headers = request + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_ascii_lowercase(), + value + .to_str() + .expect("header should be valid utf-8") + .to_string(), + ) + }) + .collect::>(); + *captured_request_for_callback + .lock() + .expect("capture lock should acquire") = Some(CapturedWebSocketRequest { + path: request.uri().path().to_string(), + headers, + }); + Ok(response) + }, + ) + .await + .expect("websocket handshake should succeed"); + let captured_request = captured_request + .lock() + .expect("capture lock should acquire") + .clone() + .expect("websocket request should be captured"); + (captured_request, websocket) +} + +async fn send_client_event( + websocket: &mut WebSocketStream, + client_envelope: ClientEnvelope, +) { + let payload = serde_json::to_string(&client_envelope).expect("client event should serialize"); + websocket + .send(tungstenite::Message::Text(payload.into())) + .await + .expect("client event should send"); +} + +async fn read_server_event(websocket: &mut WebSocketStream) -> serde_json::Value { + loop { + let frame = timeout(Duration::from_secs(5), websocket.next()) + .await + .expect("server event should arrive in time") + .expect("websocket should stay open") + .expect("websocket frame should be readable"); + match frame { + tungstenite::Message::Text(text) => { + let mut event: serde_json::Value = + serde_json::from_str(text.as_ref()).expect("server event should deserialize"); + if let Some(stream_id) = event + .as_object_mut() + .and_then(|event| event.remove("stream_id")) + { + assert!(stream_id.is_string(), "stream_id should be a string"); + } + return event; + } + tungstenite::Message::Ping(payload) => { + websocket + .send(tungstenite::Message::Pong(payload)) + .await + .expect("websocket pong should send"); + } + tungstenite::Message::Pong(_) => {} + tungstenite::Message::Close(frame) => { + panic!("unexpected websocket close frame: {frame:?}"); + } + tungstenite::Message::Binary(_) => { + panic!("unexpected binary websocket frame"); + } + tungstenite::Message::Frame(_) => {} + } + } +} diff --git a/codex-rs/app-server/src/transport/remote_control/websocket.rs b/codex-rs/app-server/src/transport/remote_control/websocket.rs new file mode 100644 index 0000000000..3f42a7bbbe --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/websocket.rs @@ -0,0 +1,1409 @@ +use crate::transport::TransportEvent; +use crate::transport::remote_control::client_tracker::ClientTracker; +use crate::transport::remote_control::client_tracker::REMOTE_CONTROL_IDLE_SWEEP_INTERVAL; +use crate::transport::remote_control::enroll::RemoteControlConnectionAuth; +use crate::transport::remote_control::enroll::RemoteControlEnrollment; +use crate::transport::remote_control::enroll::enroll_remote_control_server; +use crate::transport::remote_control::enroll::format_headers; +use crate::transport::remote_control::enroll::load_persisted_remote_control_enrollment; +use crate::transport::remote_control::enroll::preview_remote_control_response_body; +use crate::transport::remote_control::enroll::update_persisted_remote_control_enrollment; + +use super::protocol::ClientEnvelope; +use super::protocol::ClientEvent; +use super::protocol::ClientId; +use super::protocol::RemoteControlTarget; +use super::protocol::ServerEnvelope; +use axum::http::HeaderValue; +use base64::Engine; +use codex_core::util::backoff; +use codex_login::AuthManager; +use codex_login::UnauthorizedRecovery; +use codex_state::StateRuntime; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use futures::SinkExt; +use futures::StreamExt; +use futures::stream::SplitSink; +use futures::stream::SplitStream; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::io; +use std::io::ErrorKind; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tokio::time::MissedTickBehavior; +use tokio_tungstenite::MaybeTlsStream; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; +use tracing::warn; + +pub(super) const REMOTE_CONTROL_PROTOCOL_VERSION: &str = "2"; +pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; +const REMOTE_CONTROL_SUBSCRIBE_CURSOR_HEADER: &str = "x-codex-subscribe-cursor"; +const REMOTE_CONTROL_WEBSOCKET_PING_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(10); +const REMOTE_CONTROL_WEBSOCKET_PONG_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(60); +const REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(1); + +struct BoundedOutboundBuffer { + // Remote-control acks are generated by the backend at client scope, so + // retransmit retention is keyed by client_id only. stream_id stays on each + // envelope for routing, but it is not part of the ack cursor. + buffer_by_client: HashMap>, + used_tx: watch::Sender, +} + +impl BoundedOutboundBuffer { + fn new() -> (Self, watch::Receiver) { + let (used_tx, used_rx) = watch::channel(0); + let buffer = Self { + buffer_by_client: HashMap::new(), + used_tx, + }; + (buffer, used_rx) + } + + fn insert(&mut self, server_envelope: &ServerEnvelope) { + self.buffer_by_client + .entry(server_envelope.client_id.clone()) + .or_default() + .insert(server_envelope.seq_id, server_envelope.clone()); + self.used_tx.send_modify(|used| *used += 1); + } + + fn ack(&mut self, client_id: &ClientId, acked_seq_id: u64) { + let Some(buffer) = self.buffer_by_client.get_mut(client_id) else { + return; + }; + while let Some(seq_id) = buffer.first_key_value().map(|(seq_id, _)| seq_id) + && *seq_id <= acked_seq_id + { + buffer.pop_first(); + self.used_tx.send_modify(|used| *used -= 1); + } + if buffer.is_empty() { + self.buffer_by_client.remove(client_id); + } + } + + fn server_envelopes(&self) -> impl Iterator { + self.buffer_by_client + .values() + .flat_map(|buffer| buffer.values()) + } +} + +struct WebsocketState { + outbound_buffer: BoundedOutboundBuffer, + subscribe_cursor: Option, + next_seq_id: u64, +} + +pub(crate) struct RemoteControlWebsocket { + remote_control_target: RemoteControlTarget, + state_db: Option>, + auth_manager: Arc, + shutdown_token: CancellationToken, + reconnect_attempt: u64, + enrollment: Option, + auth_recovery: UnauthorizedRecovery, + client_tracker: Arc>, + state: Arc>, + server_event_rx: Arc>>, + used_rx: watch::Receiver, +} + +impl RemoteControlWebsocket { + pub(crate) fn new( + remote_control_target: RemoteControlTarget, + state_db: Option>, + auth_manager: Arc, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, + ) -> Self { + let shutdown_token = shutdown_token.child_token(); + let (server_event_tx, server_event_rx) = mpsc::channel(super::CHANNEL_CAPACITY); + let client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let auth_recovery = auth_manager.unauthorized_recovery(); + + Self { + remote_control_target, + state_db, + auth_manager, + shutdown_token, + reconnect_attempt: 0, + enrollment: None, + auth_recovery, + client_tracker: Arc::new(Mutex::new(client_tracker)), + state: Arc::new(Mutex::new(WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id: 0, + })), + server_event_rx: Arc::new(Mutex::new(server_event_rx)), + used_rx, + } + } + + pub(crate) async fn run( + mut self, + app_server_client_name_rx: Option>, + ) { + let app_server_client_name = match self + .wait_for_app_server_client_name(app_server_client_name_rx) + .await + { + Ok(app_server_client_name) => app_server_client_name, + Err(_) => { + self.client_tracker.lock().await.shutdown().await; + return; + } + }; + + loop { + let shutdown_token = self.shutdown_token.child_token(); + let websocket_connection = match self + .connect(&shutdown_token, app_server_client_name.as_deref()) + .await + { + Some(websocket_connection) => websocket_connection, + None => break, + }; + + self.run_connection(websocket_connection, shutdown_token) + .await; + } + + self.client_tracker.lock().await.shutdown().await; + } + + async fn wait_for_app_server_client_name( + &self, + app_server_client_name_rx: Option>, + ) -> Result, ()> { + match app_server_client_name_rx { + Some(app_server_client_name_rx) => { + tokio::select! { + _ = self.shutdown_token.cancelled() => Err(()), + app_server_client_name = app_server_client_name_rx => match app_server_client_name { + Ok(app_server_client_name) => Ok(Some(app_server_client_name)), + Err(_) => Err(()), + }, + } + } + None => Ok(None), + } + } + + async fn connect( + &mut self, + shutdown_token: &CancellationToken, + app_server_client_name: Option<&str>, + ) -> Option>> { + loop { + let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); + tokio::select! { + _ = shutdown_token.cancelled() => return None, + connect_result = connect_remote_control_websocket( + &self.remote_control_target, + self.state_db.as_deref(), + &self.auth_manager, + &mut self.auth_recovery, + &mut self.enrollment, + subscribe_cursor.as_deref(), + app_server_client_name, + ) => { + match connect_result { + Ok((websocket_connection, response)) => { + self.reconnect_attempt = 0; + self.auth_recovery = self.auth_manager.unauthorized_recovery(); + info!( + "connected to app-server remote control websocket: {}, {}", + self.remote_control_target.websocket_url, + format_headers(response.headers()) + ); + return Some(websocket_connection); + } + Err(err) => { + let reconnect_delay = if err.kind() == ErrorKind::WouldBlock { + info!("{err}"); + REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL + } else { + warn!("{err}"); + let reconnect_delay = backoff(self.reconnect_attempt); + self.reconnect_attempt += 1; + reconnect_delay + }; + tokio::select! { + _ = shutdown_token.cancelled() => return None, + _ = tokio::time::sleep(reconnect_delay) => {} + } + } + } + } + } + } + } + + async fn run_connection( + &self, + websocket_connection: WebSocketStream>, + shutdown_token: CancellationToken, + ) { + let (websocket_writer, websocket_reader) = websocket_connection.split(); + let mut join_set = tokio::task::JoinSet::new(); + + join_set.spawn(Self::run_server_writer( + self.state.clone(), + self.server_event_rx.clone(), + self.used_rx.clone(), + websocket_writer, + REMOTE_CONTROL_WEBSOCKET_PING_INTERVAL, + shutdown_token.clone(), + )); + join_set.spawn(Self::run_websocket_reader( + self.client_tracker.clone(), + self.state.clone(), + websocket_reader, + REMOTE_CONTROL_WEBSOCKET_PONG_TIMEOUT, + shutdown_token.clone(), + )); + + tokio::select! { + _ = shutdown_token.cancelled() => {} + _ = join_set.join_next() => shutdown_token.cancel(), + } + + join_set.join_all().await; + } + + async fn run_server_writer( + state: Arc>, + server_event_rx: Arc>>, + used_rx: watch::Receiver, + websocket_writer: SplitSink< + WebSocketStream>, + tungstenite::Message, + >, + ping_interval: std::time::Duration, + shutdown_token: CancellationToken, + ) { + let result = Self::run_server_writer_inner( + state, + server_event_rx, + used_rx, + websocket_writer, + ping_interval, + shutdown_token, + ) + .await; + if let Err(err) = result { + warn!("remote control websocket writer disconnected, err: {err}"); + } else { + warn!("remote control websocket writer was stopped"); + } + } + + async fn run_server_writer_inner( + state: Arc>, + server_event_rx: Arc>>, + mut used_rx: watch::Receiver, + mut websocket_writer: SplitSink< + WebSocketStream>, + tungstenite::Message, + >, + ping_interval: std::time::Duration, + shutdown_token: CancellationToken, + ) -> io::Result<()> { + for server_envelope in state.lock().await.outbound_buffer.server_envelopes() { + let payload = match serde_json::to_string(&server_envelope) { + Ok(payload) => payload, + Err(err) => { + error!("failed to serialize remote-control server event: {err}"); + continue; + } + }; + tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + send_result = websocket_writer.send(tungstenite::Message::Text(payload.into())) => { + if let Err(err) = send_result { + return Err(io::Error::other(err)); + } + } + }; + } + + let mut ping_interval = + tokio::time::interval_at(tokio::time::Instant::now() + ping_interval, ping_interval); + ping_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + let mut server_event_rx = server_event_rx.lock().await; + loop { + let outbound_has_capacity = *used_rx.borrow() < super::CHANNEL_CAPACITY; + let queued_server_envelope = tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + _ = ping_interval.tick() => { + if let Err(err) = websocket_writer + .send(tungstenite::Message::Ping(Vec::new().into())) + .await + { + return Err(io::Error::other(err)); + } + continue; + } + wait_result = used_rx.changed(), if !outbound_has_capacity => + { + if wait_result.is_err() { + return Err(io::Error::new( + ErrorKind::UnexpectedEof, + "outbound buffer usage channel closed", + )); + } + continue; + } + recv_result = server_event_rx.recv(), if outbound_has_capacity => { + match recv_result { + Some(queued_server_envelope) => queued_server_envelope, + None => { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "server event channel closed")); + } + } + } + }; + let (server_envelope, write_complete_tx) = { + let mut state = state.lock().await; + let seq_id = state.next_seq_id; + state.next_seq_id = state.next_seq_id.saturating_add(1); + + let server_envelope = ServerEnvelope { + event: queued_server_envelope.event, + client_id: queued_server_envelope.client_id, + seq_id, + stream_id: queued_server_envelope.stream_id, + }; + state.outbound_buffer.insert(&server_envelope); + + (server_envelope, queued_server_envelope.write_complete_tx) + }; + + let payload = match serde_json::to_string(&server_envelope) { + Ok(payload) => payload, + Err(err) => { + error!("failed to serialize remote-control server event: {err}"); + continue; + } + }; + + tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + send_result = websocket_writer.send(tungstenite::Message::Text(payload.into())) => { + if let Err(err) = send_result { + return Err(io::Error::other(err)); + } + } + }; + if let Some(write_complete_tx) = write_complete_tx { + let _ = write_complete_tx.send(()); + } + } + } + + async fn run_websocket_reader( + client_tracker: Arc>, + state: Arc>, + websocket_reader: SplitStream>>, + pong_timeout: std::time::Duration, + shutdown_token: CancellationToken, + ) { + let result = Self::run_websocket_reader_inner( + client_tracker, + state, + websocket_reader, + pong_timeout, + shutdown_token, + ) + .await; + if let Err(err) = result { + warn!("remote control websocket reader disconnected, err: {err}"); + } else { + warn!("remote control websocket reader was stopped"); + } + } + + async fn run_websocket_reader_inner( + client_tracker: Arc>, + state: Arc>, + mut websocket_reader: SplitStream>>, + pong_timeout: std::time::Duration, + shutdown_token: CancellationToken, + ) -> io::Result<()> { + let mut client_tracker = client_tracker.lock().await; + let mut idle_sweep_interval = tokio::time::interval(REMOTE_CONTROL_IDLE_SWEEP_INTERVAL); + idle_sweep_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + let pong_deadline = tokio::time::sleep(pong_timeout); + tokio::pin!(pong_deadline); + + loop { + let incoming_message = tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + _ = &mut pong_deadline => { + return Err(io::Error::new( + ErrorKind::TimedOut, + "remote control websocket pong timeout", + )); + } + client_key = client_tracker.bookkeep_join_set() => { + let Some(client_key) = client_key else { + continue; + }; + if client_tracker.close_client(&client_key).await.is_err() { + return Ok(()); + } + continue; + } + _ = idle_sweep_interval.tick() => { + if client_tracker.close_expired_clients().await.is_err() { + return Ok(()); + } + continue; + } + incoming_message = websocket_reader.next() => { + match incoming_message { + Some(incoming_message) => incoming_message, + None => return Err(io::Error::new(ErrorKind::UnexpectedEof, "websocket stream ended")), + } + } + }; + let client_envelope = match incoming_message { + Ok(tungstenite::Message::Text(text)) => { + match serde_json::from_str::(&text) { + Ok(client_envelope) => client_envelope, + Err(err) => { + warn!("failed to deserialize remote-control client event: {err}"); + continue; + } + } + } + Ok(tungstenite::Message::Pong(_)) => { + pong_deadline + .as_mut() + .reset(tokio::time::Instant::now() + pong_timeout); + continue; + } + Ok(tungstenite::Message::Ping(_)) | Ok(tungstenite::Message::Frame(_)) => continue, + Ok(tungstenite::Message::Binary(_)) => { + warn!("dropping unsupported binary remote-control websocket message"); + continue; + } + Ok(tungstenite::Message::Close(_)) => { + return Err(io::Error::new( + ErrorKind::ConnectionAborted, + "websocket disconnected", + )); + } + Err(err) => { + return Err(io::Error::new( + ErrorKind::InvalidData, + format!("failed to read from websocket: {err}"), + )); + } + }; + + let mut websocket_state = state.lock().await; + if let Some(cursor) = client_envelope.cursor.as_deref() { + websocket_state.subscribe_cursor = Some(cursor.to_string()); + } + if let ClientEvent::Ack = &client_envelope.event + && let Some(acked_seq_id) = client_envelope.seq_id + { + websocket_state + .outbound_buffer + .ack(&client_envelope.client_id, acked_seq_id); + } + drop(websocket_state); + + if client_tracker + .handle_message(client_envelope) + .await + .is_err() + { + return Ok(()); + } + } + } +} + +fn set_remote_control_header( + headers: &mut tungstenite::http::HeaderMap, + name: &'static str, + value: &str, +) -> io::Result<()> { + let header_value = HeaderValue::from_str(value).map_err(|err| { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control header `{name}`: {err}"), + ) + })?; + headers.insert(name, header_value); + Ok(()) +} + +fn build_remote_control_websocket_request( + websocket_url: &str, + enrollment: &RemoteControlEnrollment, + auth: &RemoteControlConnectionAuth, + subscribe_cursor: Option<&str>, +) -> io::Result> { + let mut request = websocket_url.into_client_request().map_err(|err| { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control websocket URL `{websocket_url}`: {err}"), + ) + })?; + let headers = request.headers_mut(); + set_remote_control_header(headers, "x-codex-server-id", &enrollment.server_id)?; + set_remote_control_header( + headers, + "x-codex-name", + &base64::engine::general_purpose::STANDARD.encode(&enrollment.server_name), + )?; + set_remote_control_header( + headers, + "x-codex-protocol-version", + REMOTE_CONTROL_PROTOCOL_VERSION, + )?; + set_remote_control_header( + headers, + "authorization", + &format!("Bearer {}", auth.bearer_token), + )?; + set_remote_control_header(headers, REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id)?; + if let Some(subscribe_cursor) = subscribe_cursor { + set_remote_control_header( + headers, + REMOTE_CONTROL_SUBSCRIBE_CURSOR_HEADER, + subscribe_cursor, + )?; + } + Ok(request) +} + +pub(crate) async fn load_remote_control_auth( + auth_manager: &Arc, +) -> io::Result { + let mut reloaded = false; + let auth = loop { + let Some(auth) = auth_manager.auth().await else { + if reloaded { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication", + )); + } + auth_manager.reload(); + reloaded = true; + continue; + }; + if !auth.is_chatgpt_auth() { + break auth; + } + if auth.get_account_id().is_none() && !reloaded { + auth_manager.reload(); + reloaded = true; + continue; + } + break auth; + }; + + if !auth.is_chatgpt_auth() { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication; API key auth is not supported", + )); + } + + Ok(RemoteControlConnectionAuth { + bearer_token: auth.get_token().map_err(io::Error::other)?, + account_id: auth.get_account_id().ok_or_else(|| { + io::Error::new( + ErrorKind::WouldBlock, + "remote control enrollment is waiting for a ChatGPT account id", + ) + })?, + }) +} + +pub(super) async fn connect_remote_control_websocket( + remote_control_target: &RemoteControlTarget, + state_db: Option<&StateRuntime>, + auth_manager: &Arc, + auth_recovery: &mut UnauthorizedRecovery, + enrollment: &mut Option, + subscribe_cursor: Option<&str>, + app_server_client_name: Option<&str>, +) -> io::Result<( + WebSocketStream>, + tungstenite::http::Response<()>, +)> { + ensure_rustls_crypto_provider(); + + let auth = load_remote_control_auth(auth_manager).await?; + let enrollment_account_id = enrollment.as_ref().map(|enrollment| &enrollment.account_id); + if enrollment_account_id.is_some_and(|account_id| account_id != &auth.account_id) { + info!( + "clearing in-memory remote control enrollment because account id changed: websocket_url={}, previous_account_id={:?}, current_account_id={:?}", + remote_control_target.websocket_url, + enrollment + .as_ref() + .map(|enrollment| enrollment.account_id.as_str()), + auth.account_id + ); + *enrollment = None; + } + + if enrollment.is_none() { + *enrollment = load_persisted_remote_control_enrollment( + state_db, + remote_control_target, + &auth.account_id, + app_server_client_name, + ) + .await; + } + + if enrollment.is_none() { + info!( + "creating new remote control enrollment: websocket_url={}, enroll_url={}, account_id={}", + remote_control_target.websocket_url, remote_control_target.enroll_url, auth.account_id + ); + let new_enrollment = match enroll_remote_control_server(remote_control_target, &auth).await + { + Ok(new_enrollment) => new_enrollment, + Err(err) + if err.kind() == ErrorKind::PermissionDenied + && recover_remote_control_auth(auth_recovery).await => + { + return Err(io::Error::other(format!( + "{err}; retrying after auth recovery" + ))); + } + Err(err) => return Err(err), + }; + if let Err(err) = update_persisted_remote_control_enrollment( + state_db, + remote_control_target, + &auth.account_id, + app_server_client_name, + Some(&new_enrollment), + ) + .await + { + warn!("failed to persist remote control enrollment in sqlite state db: {err}"); + } + info!( + "created new remote control enrollment: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + new_enrollment.account_id, + new_enrollment.server_id, + new_enrollment.environment_id + ); + *enrollment = Some(new_enrollment); + } + + let enrollment_ref = enrollment.as_ref().ok_or_else(|| { + io::Error::other("missing remote control enrollment after enrollment step") + })?; + let request = build_remote_control_websocket_request( + &remote_control_target.websocket_url, + enrollment_ref, + &auth, + subscribe_cursor, + )?; + + match connect_async(request).await { + Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), + Err(err) => { + match &err { + tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { + info!( + "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + auth.account_id, + enrollment_ref.server_id, + enrollment_ref.environment_id + ); + if let Err(clear_err) = update_persisted_remote_control_enrollment( + state_db, + remote_control_target, + &auth.account_id, + app_server_client_name, + /*enrollment*/ None, + ) + .await + { + warn!( + "failed to clear stale remote control enrollment in sqlite state db: {clear_err}" + ); + } + *enrollment = None; + } + tungstenite::Error::Http(response) + if matches!(response.status().as_u16(), 401 | 403) => + { + if recover_remote_control_auth(auth_recovery).await { + return Err(io::Error::other(format!( + "remote control websocket auth failed with HTTP {}; retrying after auth recovery", + response.status() + ))); + } + } + _ => {} + } + Err(io::Error::other( + format_remote_control_websocket_connect_error( + &remote_control_target.websocket_url, + &err, + ), + )) + } + } +} + +async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) -> bool { + if !auth_recovery.has_next() { + return false; + } + + let mode = auth_recovery.mode_name(); + let step = auth_recovery.step_name(); + match auth_recovery.next().await { + Ok(step_result) => { + info!( + "remote control websocket auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}", + step_result.auth_state_changed() + ); + true + } + Err(err) => { + warn!("remote control websocket auth recovery failed: mode={mode}, step={step}: {err}"); + false + } + } +} + +fn format_remote_control_websocket_connect_error( + websocket_url: &str, + err: &tungstenite::Error, +) -> String { + let mut message = + format!("failed to connect app-server remote control websocket `{websocket_url}`: {err}"); + let tungstenite::Error::Http(response) = err else { + return message; + }; + + message.push_str(&format!(", {}", format_headers(response.headers()))); + if let Some(body) = response.body().as_ref() + && !body.is_empty() + { + let body_preview = preview_remote_control_response_body(body); + message.push_str(&format!(", body: {body_preview}")); + } + + message +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outgoing_message::OutgoingMessage; + use crate::transport::remote_control::ServerEvent; + use crate::transport::remote_control::protocol::StreamId; + use crate::transport::remote_control::protocol::normalize_remote_control_url; + use chrono::Utc; + use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::ServerNotification; + use codex_config::types::AuthCredentialsStoreMode; + use codex_core::test_support::auth_manager_from_auth; + use codex_login::AuthDotJson; + use codex_login::CodexAuth; + use codex_login::save_auth; + use codex_login::token_data::TokenData; + use codex_login::token_data::parse_chatgpt_jwt_claims; + use codex_state::StateRuntime; + use futures::StreamExt; + use pretty_assertions::assert_eq; + use std::sync::Arc; + use tempfile::TempDir; + use tokio::io::AsyncBufReadExt; + use tokio::io::AsyncWriteExt; + use tokio::io::BufReader; + use tokio::net::TcpListener; + use tokio::net::TcpStream; + use tokio::sync::mpsc; + use tokio::time::Duration; + use tokio::time::timeout; + use tokio_tungstenite::accept_async; + + async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) + .await + .expect("state runtime should initialize") + } + + fn remote_control_auth_manager() -> Arc { + auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + } + + fn remote_control_url_for_listener(listener: &TcpListener) -> String { + format!( + "http://localhost:{}/backend-api/", + listener + .local_addr() + .expect("listener should have a local addr") + .port() + ) + } + + fn remote_control_auth_dot_json(access_token: &str) -> AuthDotJson { + #[derive(serde::Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + "chatgpt_account_id": "account_id" + } + }); + let b64 = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = b64(&serde_json::to_vec(&header).expect("header should serialize")); + let payload_b64 = b64(&serde_json::to_vec(&payload).expect("payload should serialize")); + let fake_jwt = format!("{header_b64}.{payload_b64}.sig"); + + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: parse_chatgpt_jwt_claims(&fake_jwt).expect("fake jwt should parse"), + access_token: access_token.to_string(), + refresh_token: "refresh-token".to_string(), + account_id: Some("account_id".to_string()), + }), + last_refresh: Some(Utc::now()), + } + } + + #[tokio::test] + async fn connect_remote_control_websocket_includes_http_error_details() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let expected_error = format!( + "failed to connect app-server remote control websocket `{}`: HTTP error: 503 Service Unavailable, request-id: , cf-ray: , body: upstream unavailable", + remote_control_target.websocket_url + ); + let server_task = tokio::spawn(async move { + let (stream, request_line) = accept_http_request(&listener).await; + assert_eq!( + request_line, + "GET /backend-api/wham/remote/control/server HTTP/1.1" + ); + respond_with_status_and_headers( + stream, + "503 Service Unavailable", + &[("x-trace-id", "trace-503"), ("x-region", "us-east-1")], + "upstream unavailable", + ) + .await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = remote_control_auth_manager(); + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut enrollment = Some(RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: "test-server".to_string(), + }); + + let err = match connect_remote_control_websocket( + &remote_control_target, + Some(state_db.as_ref()), + &auth_manager, + &mut auth_recovery, + &mut enrollment, + /*subscribe_cursor*/ None, + /*app_server_client_name*/ None, + ) + .await + { + Ok(_) => panic!("http error response should fail the websocket connect"), + Err(err) => err, + }; + + server_task.await.expect("server task should succeed"); + assert_eq!(err.to_string(), expected_error); + } + + #[tokio::test] + async fn connect_remote_control_websocket_recovers_after_unauthorized_reload() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let server_task = tokio::spawn(async move { + let (stream, request_line) = accept_http_request(&listener).await; + assert_eq!( + request_line, + "GET /backend-api/wham/remote/control/server HTTP/1.1" + ); + respond_with_status_and_headers(stream, "401 Unauthorized", &[], "unauthorized").await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("stale-token"), + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + ); + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut enrollment = Some(RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: "test-server".to_string(), + }); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("fresh-token"), + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let err = connect_remote_control_websocket( + &remote_control_target, + Some(state_db.as_ref()), + &auth_manager, + &mut auth_recovery, + &mut enrollment, + /*subscribe_cursor*/ None, + /*app_server_client_name*/ None, + ) + .await + .expect_err("unauthorized response should fail the websocket connect"); + + server_task.await.expect("server task should succeed"); + assert_eq!( + err.to_string(), + "remote control websocket auth failed with HTTP 401 Unauthorized; retrying after auth recovery" + ); + assert_eq!( + auth_manager + .auth() + .await + .expect("auth should remain available") + .get_token() + .expect("token should be readable"), + "fresh-token" + ); + } + + #[tokio::test] + async fn connect_remote_control_websocket_recovers_after_unauthorized_enrollment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let enroll_url = remote_control_target.enroll_url.clone(); + let server_task = tokio::spawn(async move { + let (stream, request_line) = accept_http_request(&listener).await; + assert_eq!( + request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_status_and_headers(stream, "401 Unauthorized", &[], "unauthorized").await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("stale-token"), + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + ); + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut enrollment = None; + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("fresh-token"), + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let err = connect_remote_control_websocket( + &remote_control_target, + Some(state_db.as_ref()), + &auth_manager, + &mut auth_recovery, + &mut enrollment, + /*subscribe_cursor*/ None, + /*app_server_client_name*/ None, + ) + .await + .expect_err("unauthorized enrollment should fail the websocket connect"); + + server_task.await.expect("server task should succeed"); + assert_eq!( + err.to_string(), + format!( + "remote control server enrollment failed at `{enroll_url}`: HTTP 401 Unauthorized, request-id: , cf-ray: , body: unauthorized; retrying after auth recovery" + ) + ); + assert_eq!( + auth_manager + .auth() + .await + .expect("auth should remain available") + .get_token() + .expect("token should be readable"), + "fresh-token" + ); + } + + #[tokio::test] + async fn run_remote_control_websocket_loop_shutdown_cancels_reconnect_backoff() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + drop(listener); + + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let (transport_event_tx, transport_event_rx) = mpsc::channel(1); + drop(transport_event_rx); + let shutdown_token = CancellationToken::new(); + let websocket_task = tokio::spawn({ + let shutdown_token = shutdown_token.clone(); + async move { + RemoteControlWebsocket::new( + remote_control_target, + /*state_db*/ None, + remote_control_auth_manager(), + transport_event_tx, + shutdown_token, + ) + .run(/*app_server_client_name_rx*/ None) + .await + } + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + shutdown_token.cancel(); + + timeout(Duration::from_millis(100), websocket_task) + .await + .expect("shutdown should cancel reconnect backoff") + .expect("websocket task should join"); + } + + #[tokio::test] + async fn run_server_writer_inner_sends_periodic_ping_frames() { + let (client_stream, mut server_stream) = connected_websocket_pair().await; + let (websocket_writer, _websocket_reader) = client_stream.split(); + let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let state = Arc::new(Mutex::new(WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id: 0, + })); + let (_server_event_tx, server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY); + let server_event_rx = Arc::new(Mutex::new(server_event_rx)); + let shutdown_token = CancellationToken::new(); + let writer_task = tokio::spawn(RemoteControlWebsocket::run_server_writer_inner( + state, + server_event_rx, + used_rx, + websocket_writer, + Duration::from_millis(20), + shutdown_token.clone(), + )); + + let message = timeout(Duration::from_secs(5), server_stream.next()) + .await + .expect("ping frame should arrive in time") + .expect("server websocket should stay open") + .expect("ping frame should read"); + assert!(matches!(message, tungstenite::Message::Ping(_))); + + shutdown_token.cancel(); + writer_task + .await + .expect("writer task should join") + .expect("writer should stop cleanly"); + } + + #[tokio::test] + async fn run_websocket_reader_inner_times_out_without_pong_frames() { + let (client_stream, _server_stream) = connected_websocket_pair().await; + let (_websocket_writer, websocket_reader) = client_stream.split(); + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let state = Arc::new(Mutex::new(WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id: 0, + })); + let (server_event_tx, _server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY); + let (transport_event_tx, _transport_event_rx) = + mpsc::channel(super::super::CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let client_tracker = Arc::new(Mutex::new(ClientTracker::new( + server_event_tx, + transport_event_tx, + &shutdown_token, + ))); + + let err = timeout( + Duration::from_secs(5), + RemoteControlWebsocket::run_websocket_reader_inner( + client_tracker, + state, + websocket_reader, + Duration::from_millis(100), + shutdown_token, + ), + ) + .await + .expect("reader should time out waiting for pong") + .expect_err("missing pong should fail the websocket reader"); + + assert_eq!(err.kind(), ErrorKind::TimedOut); + assert_eq!(err.to_string(), "remote control websocket pong timeout"); + } + + #[test] + fn outbound_buffer_acks_by_client_id_across_stream_ids() { + let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let client_1 = ClientId("client-1".to_string()); + let client_2 = ClientId("client-2".to_string()); + + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-1", + /*seq_id*/ 0, + "first-client-old-stream", + )); + outbound_buffer.insert(&server_envelope( + &client_2, + "stream-1", + /*seq_id*/ 1, + "second-client", + )); + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-2", + /*seq_id*/ 2, + "first-client-new-stream", + )); + + outbound_buffer.ack(&client_1, /*acked_seq_id*/ 2); + + let mut retained = outbound_buffer + .server_envelopes() + .map(|server_envelope| { + ( + server_envelope.client_id.0.as_str(), + server_envelope.stream_id.0.as_str(), + server_envelope.seq_id, + ) + }) + .collect::>(); + retained.sort_unstable(); + assert_eq!(retained, vec![("client-2", "stream-1", 1)]); + assert_eq!(*used_rx.borrow(), 1); + } + + #[test] + fn outbound_buffer_retains_unacked_messages_until_ack_advances() { + let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let client_1 = ClientId("client-1".to_string()); + let client_2 = ClientId("client-2".to_string()); + + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-1", + /*seq_id*/ 0, + "first-old", + )); + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-2", + /*seq_id*/ 1, + "first-new", + )); + outbound_buffer.insert(&server_envelope( + &client_2, "stream-1", /*seq_id*/ 2, "second", + )); + + outbound_buffer.ack(&client_1, /*acked_seq_id*/ 0); + + let mut retained = outbound_buffer + .server_envelopes() + .map(|server_envelope| { + ( + server_envelope.client_id.0.as_str(), + server_envelope.stream_id.0.as_str(), + server_envelope.seq_id, + ) + }) + .collect::>(); + retained.sort_unstable(); + assert_eq!( + retained, + vec![("client-1", "stream-2", 1), ("client-2", "stream-1", 2)] + ); + assert_eq!(*used_rx.borrow(), 2); + } + + fn server_envelope( + client_id: &ClientId, + stream_id: &str, + seq_id: u64, + summary: &str, + ) -> ServerEnvelope { + ServerEnvelope { + event: ServerEvent::ServerMessage { + message: Box::new(OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: summary.to_string(), + details: None, + path: None, + range: None, + }), + )), + }, + client_id: client_id.clone(), + stream_id: StreamId(stream_id.to_string()), + seq_id, + } + } + + async fn accept_http_request(listener: &TcpListener) -> (TcpStream, String) { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("HTTP request should arrive in time") + .expect("listener accept should succeed"); + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("request line should read"); + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .expect("header line should read"); + if line == "\r\n" { + break; + } + } + + ( + reader.into_inner(), + request_line.trim_end_matches("\r\n").to_string(), + ) + } + + async fn connected_websocket_pair() -> ( + WebSocketStream>, + WebSocketStream, + ) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let connect_task = tokio::spawn(connect_async(format!( + "ws://{}", + listener + .local_addr() + .expect("listener should have a local addr") + ))); + let (server_stream, _) = listener + .accept() + .await + .expect("server should accept client"); + let server_stream = accept_async(server_stream) + .await + .expect("server websocket handshake should succeed"); + let (client_stream, _) = connect_task + .await + .expect("client connect task should join") + .expect("client websocket handshake should succeed"); + + (client_stream, server_stream) + } + + async fn respond_with_status_and_headers( + mut stream: TcpStream, + status: &str, + headers: &[(&str, &str)], + body: &str, + ) { + let extra_headers = headers + .iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n{extra_headers}\r\n{body}", + body.len(), + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); + } +} diff --git a/codex-rs/app-server/src/transport/stdio.rs b/codex-rs/app-server/src/transport/stdio.rs index 4f2bf26745..20eab025fb 100644 --- a/codex-rs/app-server/src/transport/stdio.rs +++ b/codex-rs/app-server/src/transport/stdio.rs @@ -1,9 +1,12 @@ use super::CHANNEL_CAPACITY; use super::TransportEvent; use super::forward_incoming_message; +use super::next_connection_id; use super::serialize_outgoing_message; -use crate::outgoing_message::ConnectionId; use crate::outgoing_message::QueuedOutgoingMessage; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCRequest; use std::io::ErrorKind; use std::io::Result as IoResult; use tokio::io; @@ -11,6 +14,7 @@ use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::sync::mpsc; +use tokio::sync::oneshot; use tokio::task::JoinHandle; use tracing::debug; use tracing::error; @@ -19,8 +23,9 @@ use tracing::info; pub(crate) async fn start_stdio_connection( transport_event_tx: mpsc::Sender, stdio_handles: &mut Vec>, + initialize_client_name_tx: oneshot::Sender, ) -> IoResult<()> { - let connection_id = ConnectionId(0); + let connection_id = next_connection_id(); let (writer_tx, mut writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); let writer_tx_for_reader = writer_tx.clone(); transport_event_tx @@ -37,10 +42,16 @@ pub(crate) async fn start_stdio_connection( let stdin = io::stdin(); let reader = BufReader::new(stdin); let mut lines = reader.lines(); + let mut initialize_client_name_tx = Some(initialize_client_name_tx); loop { match lines.next_line().await { Ok(Some(line)) => { + if let Some(client_name) = stdio_initialize_client_name(&line) + && let Some(initialize_client_name_tx) = initialize_client_name_tx.take() + { + let _ = initialize_client_name_tx.send(client_name); + } if !forward_incoming_message( &transport_event_tx_for_reader, &writer_tx_for_reader, @@ -86,3 +97,15 @@ pub(crate) async fn start_stdio_connection( Ok(()) } + +fn stdio_initialize_client_name(line: &str) -> Option { + let message = serde_json::from_str::(line).ok()?; + let JSONRPCMessage::Request(JSONRPCRequest { method, params, .. }) = message else { + return None; + }; + if method != "initialize" { + return None; + } + let params = serde_json::from_value::(params?).ok()?; + Some(params.client_info.name) +} diff --git a/codex-rs/app-server/src/transport/websocket.rs b/codex-rs/app-server/src/transport/websocket.rs index 05dfe24b05..41b138e216 100644 --- a/codex-rs/app-server/src/transport/websocket.rs +++ b/codex-rs/app-server/src/transport/websocket.rs @@ -4,6 +4,7 @@ use super::auth::WebsocketAuthPolicy; use super::auth::authorize_upgrade; use super::auth::should_warn_about_unauthenticated_non_loopback_listener; use super::forward_incoming_message; +use super::next_connection_id; use super::serialize_outgoing_message; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::QueuedOutgoingMessage; @@ -32,8 +33,6 @@ use owo_colors::Style; use std::io::Result as IoResult; use std::net::SocketAddr; use std::sync::Arc; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; use tokio::net::TcpListener; use tokio::sync::mpsc; use tokio::task::JoinHandle; @@ -75,7 +74,6 @@ fn print_websocket_startup_banner(addr: SocketAddr) { #[derive(Clone)] struct WebSocketListenerState { transport_event_tx: mpsc::Sender, - connection_counter: Arc, auth_policy: Arc, } @@ -113,7 +111,7 @@ async fn websocket_upgrade_handler( ); return (err.status_code(), err.message()).into_response(); } - let connection_id = ConnectionId(state.connection_counter.fetch_add(1, Ordering::Relaxed)); + let connection_id = next_connection_id(); info!(%peer_addr, "websocket client connected"); websocket .on_upgrade(move |stream| async move { @@ -146,7 +144,6 @@ pub(crate) async fn start_websocket_acceptor( .layer(middleware::from_fn(reject_requests_with_origin_header)) .with_state(WebSocketListenerState { transport_event_tx, - connection_counter: Arc::new(AtomicU64::new(1)), auth_policy: Arc::new(auth_policy), }); let server = axum::serve( diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index 6eb1a2c588..aef2f58dff 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -15,6 +15,7 @@ anyhow = { workspace = true } base64 = { workspace = true } chrono = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-config = { workspace = true } codex-core = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index dfda247254..99334f0770 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -7,7 +7,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::DateTime; use chrono::Utc; use codex_app_server_protocol::AuthMode; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; use codex_login::save_auth; use codex_login::token_data::TokenData; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 28e48dfdaf..e660b82646 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -47,6 +47,7 @@ use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::LoginAccountParams; +use codex_app_server_protocol::McpResourceReadParams; use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::PluginInstallParams; @@ -482,6 +483,15 @@ impl McpProcess { self.send_request("app/list", params).await } + /// Send an `mcpServer/resource/read` JSON-RPC request. + pub async fn send_mcp_resource_read_request( + &mut self, + params: McpResourceReadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("mcpServer/resource/read", params).await + } + /// Send a `skills/list` JSON-RPC request. pub async fn send_skills_list_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 78572d8ce1..e6134e4802 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -12,7 +12,7 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use pretty_assertions::assert_eq; use std::path::Path; diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 0755c37553..3c88bcb7a4 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -28,7 +28,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStatus; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::login_with_api_key; use codex_protocol::account::PlanType as AccountPlanType; use core_test_support::responses; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..a4d7a7f349 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -2,10 +2,10 @@ use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::write_chatgpt_auth; +use codex_config::types::AuthCredentialsStoreMode; use codex_config::types::OtelExporterKind; use codex_config::types::OtelHttpProtocol; use codex_core::config::ConfigBuilder; -use codex_login::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; use serde_json::Value; use std::collections::HashMap; diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index d8cedf2a41..57a27961af 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -35,7 +35,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; use codex_login::save_auth; use pretty_assertions::assert_eq; diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 8849b39abb..e7661546ac 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -28,7 +28,7 @@ use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use core_test_support::responses; diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs new file mode 100644 index 0000000000..0a38f3b37e --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -0,0 +1,218 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use axum::Router; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpResourceContent; +use codex_app_server_protocol::McpResourceReadParams; +use codex_app_server_protocol::McpResourceReadResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_config::types::AuthCredentialsStoreMode; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::ProtocolVersion; +use rmcp::model::ReadResourceRequestParams; +use rmcp::model::ReadResourceResult; +use rmcp::model::ResourceContents; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::service::RequestContext; +use rmcp::service::RoleServer; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const TEST_RESOURCE_URI: &str = "test://codex/resource"; +const TEST_BLOB_RESOURCE_URI: &str = "test://codex/resource.bin"; +const TEST_RESOURCE_BLOB: &str = "YmluYXJ5LXJlc291cmNl"; +const TEST_RESOURCE_TEXT: &str = "Resource body from the MCP server."; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_resource_read_returns_resource_contents() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let apps_server_url = format!("http://{addr}"); + + let mcp_service = StreamableHttpService::new( + move || Ok(ResourceAppsMcpServer), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new().nest_service("/api/codex/apps", mcp_service); + let apps_server_handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + let codex_home = TempDir::new()?; + let responses_server_uri = responses_server.uri(); + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" +chatgpt_base_url = "{apps_server_url}" +mcp_oauth_credentials_store = "file" + +[features] +apps = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{responses_server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + )?; + 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 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()), + ..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 read_request_id = mcp + .send_mcp_resource_read_request(McpResourceReadParams { + thread_id: thread.id, + server: "codex_apps".to_string(), + uri: TEST_RESOURCE_URI.to_string(), + }) + .await?; + let read_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)), + ) + .await??; + + assert_eq!( + to_response::(read_response)?, + McpResourceReadResponse { + contents: vec![ + McpResourceContent::Text { + uri: TEST_RESOURCE_URI.to_string(), + mime_type: Some("text/markdown".to_string()), + text: TEST_RESOURCE_TEXT.to_string(), + meta: None, + }, + McpResourceContent::Blob { + uri: TEST_BLOB_RESOURCE_URI.to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: TEST_RESOURCE_BLOB.to_string(), + meta: None, + }, + ], + } + ); + + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_mcp_resource_read_request(McpResourceReadParams { + thread_id: "00000000-0000-4000-8000-000000000000".to_string(), + server: "codex_apps".to_string(), + uri: TEST_RESOURCE_URI.to_string(), + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert!( + error.error.message.contains("thread not found"), + "expected thread-not-found error, got: {error:?}" + ); + + Ok(()) +} + +#[derive(Clone, Default)] +struct ResourceAppsMcpServer; + +impl ServerHandler for ResourceAppsMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2025_06_18, + capabilities: ServerCapabilities::builder().enable_resources().build(), + ..ServerInfo::default() + } + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let uri = request.uri; + if uri != TEST_RESOURCE_URI { + return Err(rmcp::ErrorData::resource_not_found( + format!("resource not found: {uri}"), + None, + )); + } + + Ok(ReadResourceResult { + contents: vec![ + ResourceContents::TextResourceContents { + uri: TEST_RESOURCE_URI.to_string(), + mime_type: Some("text/markdown".to_string()), + text: TEST_RESOURCE_TEXT.to_string(), + meta: None, + }, + ResourceContents::BlobResourceContents { + uri: TEST_BLOB_RESOURCE_URI.to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: TEST_RESOURCE_BLOB.to_string(), + meta: None, + }, + ], + }) + } +} diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 7a50092b99..6326677250 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -31,7 +31,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index 8b3844b521..e637be237d 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -12,15 +12,20 @@ use app_test_support::write_mock_responses_config_toml; use axum::Router; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; +use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::RequestId; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::JsonObject; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; +use rmcp::model::PaginatedRequestParams; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; use rmcp::model::ToolAnnotations; +use rmcp::service::RequestContext; use rmcp::transport::StreamableHttpServerConfig; use rmcp::transport::StreamableHttpService; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; @@ -64,6 +69,7 @@ url = "{mcp_server_url}/mcp" .send_list_mcp_server_status_request(ListMcpServerStatusParams { cursor: None, limit: None, + detail: None, }) .await?; let response = timeout( @@ -127,6 +133,133 @@ impl ServerHandler for McpStatusServer { } } +#[derive(Clone)] +struct SlowInventoryServer { + tool_name: Arc, +} + +impl ServerHandler for SlowInventoryServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ..ServerInfo::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let input_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + })) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let mut tool = Tool::new( + Cow::Owned(self.tool_name.as_ref().clone()), + Cow::Borrowed("Look up test data."), + Arc::new(input_schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + Ok(ListToolsResult { + tools: vec![tool], + next_cursor: None, + meta: None, + }) + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + tokio::time::sleep(Duration::from_secs(2)).await; + Ok(ListResourcesResult { + resources: Vec::new(), + next_cursor: None, + meta: None, + }) + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + tokio::time::sleep(Duration::from_secs(2)).await; + Ok(ListResourceTemplatesResult { + resource_templates: Vec::new(), + next_cursor: None, + meta: None, + }) + } +} + +#[tokio::test] +async fn mcp_server_status_list_tools_and_auth_only_skips_slow_inventory_calls() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let (mcp_server_url, mcp_server_handle) = start_slow_inventory_mcp_server("lookup").await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &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.some-server] +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 request_id = mcp + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), + }) + .await?; + let response = timeout( + Duration::from_millis(500), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ListMcpServerStatusResponse = to_response(response)?; + + assert_eq!(response.next_cursor, None); + assert_eq!(response.data.len(), 1); + let status = &response.data[0]; + assert_eq!(status.name, "some-server"); + assert_eq!( + status.tools.keys().cloned().collect::>(), + BTreeSet::from(["lookup".to_string()]) + ); + assert_eq!(status.resources, Vec::new()); + assert_eq!(status.resource_templates, Vec::new()); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + #[tokio::test] async fn mcp_server_status_list_does_not_duplicate_tools_for_sanitized_name_collisions() -> Result<()> { @@ -165,6 +298,7 @@ url = "{underscore_server_url}/mcp" .send_list_mcp_server_status_request(ListMcpServerStatusParams { cursor: None, limit: None, + detail: None, }) .await?; let response = timeout( @@ -215,3 +349,25 @@ async fn start_mcp_server(tool_name: &str) -> Result<(String, JoinHandle<()>)> { Ok((format!("http://{addr}"), handle)) } + +async fn start_slow_inventory_mcp_server(tool_name: &str) -> Result<(String, JoinHandle<()>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let tool_name = Arc::new(tool_name.to_string()); + let mcp_service = StreamableHttpService::new( + move || { + Ok(SlowInventoryServer { + tool_name: Arc::clone(&tool_name), + }) + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new().nest_service("/mcp", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index b8d5546580..6cd67daa58 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -14,6 +14,7 @@ mod experimental_api; mod experimental_feature_list; mod fs; mod initialize; +mod mcp_resource; mod mcp_server_elicitation; mod mcp_server_status; mod model_list; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 95630079cc..a3bea53172 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -25,7 +25,7 @@ use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::RequestId; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index d1ee1aa203..8bc4a8598e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -15,8 +15,8 @@ use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; -use codex_login::AuthCredentialsStoreMode; use codex_protocol::config_types::TrustLevel; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 32e799468e..115a04f0f5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -23,7 +23,7 @@ use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::RequestId; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index d0122c62f9..00fabe4832 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -11,7 +11,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 64c155081b..203d664940 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -10,7 +10,7 @@ use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; use serde_json::json; diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 0849fe9b32..9907fc4b1d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -24,7 +24,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use pretty_assertions::assert_eq; use serde_json::Value; diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index f5c793f744..8bf9a8a9aa 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -223,59 +223,6 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() - Ok(()) } -#[tokio::test] -async fn thread_metadata_update_repairs_stored_thread_before_backfill_completes() -> Result<()> { - let server = create_mock_responses_server_repeating_assistant("Done").await; - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; - let _state_db = - StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; - - let preview = "Stored thread preview before backfill"; - let thread_id = create_fake_rollout( - codex_home.path(), - "2025-01-05T12-30-00", - "2025-01-05T12:30:00Z", - preview, - Some("mock_provider"), - /*git_info*/ None, - )?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let update_id = mcp - .send_thread_metadata_update_request(ThreadMetadataUpdateParams { - thread_id: thread_id.clone(), - git_info: Some(ThreadMetadataGitInfoUpdateParams { - sha: None, - branch: Some(Some("feature/pending-backfill".to_string())), - origin_url: None, - }), - }) - .await?; - let update_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(update_id)), - ) - .await??; - let ThreadMetadataUpdateResponse { thread: updated } = - to_response::(update_resp)?; - - assert_eq!(updated.id, thread_id); - assert_eq!(updated.preview, preview); - assert_eq!( - updated.git_info, - Some(GitInfo { - sha: None, - branch: Some("feature/pending-backfill".to_string()), - origin_url: None, - }) - ); - - Ok(()) -} - #[tokio::test] async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 83ed147e12..80ae756888 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -38,7 +38,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::ThreadId; use codex_protocol::config_types::Personality; @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 7907e621b8..50c373ac93 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -18,9 +18,9 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadStatusChangedNotification; +use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; use codex_git_utils::resolve_root_git_project_for_trust; -use codex_login::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..2850c7b74f 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -50,7 +50,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -124,14 +124,11 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { #[tokio::test] async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<()> { - #[cfg(target_os = "windows")] let shell_command = vec![ - "powershell".to_string(), - "-Command".to_string(), - "Start-Sleep -Seconds 10".to_string(), + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), ]; - #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); @@ -143,10 +140,10 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() shell_command.clone(), Some(&working_directory), Some(10_000), - "call_sleep_approval", + "call_python_approval", )?]) .await; - create_config_toml(&codex_home, &server.uri(), "untrusted")?; + create_config_toml(&codex_home, &server.uri(), "untrusted", "read-only")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -168,7 +165,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), input: vec![V2UserInput::Text { - text: "run sleep".to_string(), + text: "run python".to_string(), text_elements: Vec::new(), }], cwd: Some(working_directory), @@ -190,7 +187,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() let ServerRequest::CommandExecutionRequestApproval { request_id, params } = request else { panic!("expected CommandExecutionRequestApproval request"); }; - assert_eq!(params.item_id, "call_sleep_approval"); + assert_eq!(params.item_id, "call_python_approval"); assert_eq!(params.thread_id, thread.id); assert_eq!(params.turn_id, turn.id); @@ -242,6 +239,7 @@ fn create_config_toml( codex_home: &std::path::Path, server_uri: &str, approval_policy: &str, + sandbox_mode: &str, ) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( @@ -250,7 +248,7 @@ fn create_config_toml( r#" model = "mock-model" approval_policy = "{approval_policy}" -sandbox_mode = "danger-full-access" +sandbox_mode = "{sandbox_mode}" model_provider = "mock_provider" diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index b99d1cb73e..59591dd0d1 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -43,7 +43,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; -use codex_core::config::ConfigToml; +use codex_config::config_toml::ConfigToml; use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME; use codex_features::FEATURES; use codex_features::Feature; diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 84c793b536..381b4fc873 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -11,15 +11,16 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-connectors = { workspace = true } +codex-config = { workspace = true } codex-core = { workspace = true } +codex-git-utils = { workspace = true } codex-login = { workspace = true } codex-utils-cli = { workspace = true } -codex-utils-cargo-bin = { workspace = true } serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } -codex-git-utils = { workspace = true } [dev-dependencies] +codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } +serde_json = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index 4f6c492a43..d20a7e57c4 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -1,4 +1,4 @@ -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthManager; use codex_login::token_data::TokenData; use std::path::Path; diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 8ba12b3460..1ea293f974 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -1,5 +1,6 @@ use codex_core::config::Config; use codex_login::AuthManager; +use codex_login::CodexAuth; use codex_login::token_data::TokenData; use std::collections::HashSet; use std::time::Duration; @@ -32,7 +33,10 @@ async fn apps_enabled(config: &Config) -> bool { /*enable_codex_api_key_env*/ false, config.cli_auth_credentials_store_mode, ); - config.features.apps_enabled(Some(&auth_manager)).await + let auth = auth_manager.auth().await; + config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth)) } pub async fn list_connectors(config: &Config) -> anyhow::Result> { if !apps_enabled(config).await { diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index ced9d4931c..9fa7dc4508 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -8,8 +8,8 @@ //! support can request from users. use codex_app_server_protocol::AuthMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::Config; -use codex_login::AuthCredentialsStoreMode; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1c4f5068fd..3007048a2f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -346,7 +346,7 @@ struct AppServerCommand { subcommand: Option, /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `ws://IP:PORT`. + /// `ws://IP:PORT`, `off`. #[arg( long = "listen", value_name = "URL", @@ -1993,6 +1993,12 @@ mod tests { ); } + #[test] + fn app_server_listen_off_parses() { + let app_server = app_server_from_args(["codex", "app-server", "--listen", "off"].as_ref()); + assert_eq!(app_server.listen, codex_app_server::AppServerTransport::Off); + } + #[test] fn app_server_listen_invalid_url_fails_to_parse() { let parse_result = diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 144316b50e..027a88a7c5 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -194,7 +194,7 @@ impl McpCli { async fn perform_oauth_login_retry_without_scopes( name: &str, url: &str, - store_mode: codex_rmcp_client::OAuthCredentialsStoreMode, + store_mode: codex_config::types::OAuthCredentialsStoreMode, http_headers: Option>, env_http_headers: Option>, resolved_scopes: &ResolvedMcpOAuthScopes, diff --git a/codex-rs/cloud-requirements/Cargo.toml b/codex-rs/cloud-requirements/Cargo.toml index 9eda7a7c36..59f8741cdb 100644 --- a/codex-rs/cloud-requirements/Cargo.toml +++ b/codex-rs/cloud-requirements/Cargo.toml @@ -12,6 +12,7 @@ async-trait = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-backend-client = { workspace = true } +codex-config = { workspace = true } codex-core = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index f2916c2e9b..04463a7dd4 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -15,12 +15,12 @@ use chrono::DateTime; use chrono::Duration as ChronoDuration; use chrono::Utc; use codex_backend_client::Client as BackendClient; +use codex_config::types::AuthCredentialsStoreMode; use codex_core::config_loader::CloudRequirementsLoadError; use codex_core::config_loader::CloudRequirementsLoadErrorCode; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigRequirementsToml; use codex_core::util::backoff; -use codex_login::AuthCredentialsStoreMode; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::RefreshTokenError; @@ -820,7 +820,7 @@ mod tests { use super::*; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use codex_login::AuthCredentialsStoreMode; + use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; use serde_json::json; diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 3a87fef092..76ede634ba 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -26,6 +26,8 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. - `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. - `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. +- `setTimeout(callback: () => void, delayMs?: number)`: schedules a callback to run later and returns a timeout id. Pending timeouts do not keep `exec` alive by themselves; await an explicit promise if you need to wait for one. +- `clearTimeout(timeoutId?: number)`: cancels a timeout created by `setTimeout`. - `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries. - `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#; const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`. @@ -556,4 +558,11 @@ mod tests { ); assert!(description.contains("### `foo` (`foo`)")); } + + #[test] + fn exec_description_mentions_timeout_helpers() { + let description = build_exec_tool_description(&[], /*code_mode_only*/ false); + assert!(description.contains("`setTimeout(callback: () => void, delayMs?: number)`")); + assert!(description.contains("`clearTimeout(timeoutId?: number)`")); + } } diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs index b77ae82d68..5511baca23 100644 --- a/codex-rs/code-mode/src/runtime/callbacks.rs +++ b/codex-rs/code-mode/src/runtime/callbacks.rs @@ -3,6 +3,7 @@ use crate::response::FunctionCallOutputContentItem; use super::EXIT_SENTINEL; use super::RuntimeEvent; use super::RuntimeState; +use super::timers; use super::value::json_to_v8; use super::value::normalize_output_image; use super::value::serialize_output_text; @@ -185,6 +186,35 @@ pub(super) fn notify_callback( retval.set(v8::undefined(scope).into()); } +pub(super) fn set_timeout_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let timeout_id = match timers::schedule_timeout(scope, args) { + Ok(timeout_id) => timeout_id, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + + retval.set(v8::Number::new(scope, timeout_id as f64).into()); +} + +pub(super) fn clear_timeout_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + if let Err(error_text) = timers::clear_timeout(scope, args) { + throw_type_error(scope, &error_text); + return; + } + + retval.set(v8::undefined(scope).into()); +} + pub(super) fn yield_control_callback( scope: &mut v8::PinScope<'_, '_>, _args: v8::FunctionCallbackArguments, diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode/src/runtime/globals.rs index 371479497b..2d419db908 100644 --- a/codex-rs/code-mode/src/runtime/globals.rs +++ b/codex-rs/code-mode/src/runtime/globals.rs @@ -1,8 +1,10 @@ use super::RuntimeState; +use super::callbacks::clear_timeout_callback; use super::callbacks::exit_callback; use super::callbacks::image_callback; use super::callbacks::load_callback; use super::callbacks::notify_callback; +use super::callbacks::set_timeout_callback; use super::callbacks::store_callback; use super::callbacks::text_callback; use super::callbacks::tool_callback; @@ -18,6 +20,8 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St let tools = build_tools_object(scope)?; let all_tools = build_all_tools_value(scope)?; + let clear_timeout = helper_function(scope, "clearTimeout", clear_timeout_callback)?; + let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?; let text = helper_function(scope, "text", text_callback)?; let image = helper_function(scope, "image", image_callback)?; let store = helper_function(scope, "store", store_callback)?; @@ -28,6 +32,8 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St set_global(scope, global, "tools", tools.into())?; set_global(scope, global, "ALL_TOOLS", all_tools)?; + set_global(scope, global, "clearTimeout", clear_timeout.into())?; + set_global(scope, global, "setTimeout", set_timeout.into())?; set_global(scope, global, "text", text.into())?; set_global(scope, global, "image", image.into())?; set_global(scope, global, "store", store.into())?; diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index df90eda673..411f81bddc 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -1,6 +1,7 @@ mod callbacks; mod globals; mod module_loader; +mod timers; mod value; use std::collections::HashMap; @@ -75,6 +76,7 @@ pub(crate) enum TurnMessage { pub(crate) enum RuntimeCommand { ToolResponse { id: String, result: JsonValue }, ToolError { id: String, error_text: String }, + TimeoutFired { id: u64 }, Terminate, } @@ -103,6 +105,7 @@ pub(crate) fn spawn_runtime( event_tx: mpsc::UnboundedSender, ) -> Result<(std_mpsc::Sender, v8::IsolateHandle), String> { let (command_tx, command_rx) = std_mpsc::channel(); + let runtime_command_tx = command_tx.clone(); let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1); let enabled_tools = request .enabled_tools @@ -117,7 +120,13 @@ pub(crate) fn spawn_runtime( }; thread::spawn(move || { - run_runtime(config, event_tx, command_rx, isolate_handle_tx); + run_runtime( + config, + event_tx, + command_rx, + isolate_handle_tx, + runtime_command_tx, + ); }); let isolate_handle = isolate_handle_rx @@ -137,10 +146,13 @@ struct RuntimeConfig { pub(super) struct RuntimeState { event_tx: mpsc::UnboundedSender, pending_tool_calls: HashMap>, + pending_timeouts: HashMap, stored_values: HashMap, enabled_tools: Vec, next_tool_call_id: u64, + next_timeout_id: u64, tool_call_id: String, + runtime_command_tx: std_mpsc::Sender, exit_requested: bool, } @@ -168,6 +180,7 @@ fn run_runtime( event_tx: mpsc::UnboundedSender, command_rx: std_mpsc::Receiver, isolate_handle_tx: std_mpsc::SyncSender, + runtime_command_tx: std_mpsc::Sender, ) { initialize_v8(); @@ -185,10 +198,13 @@ fn run_runtime( scope.set_slot(RuntimeState { event_tx: event_tx.clone(), pending_tool_calls: HashMap::new(), + pending_timeouts: HashMap::new(), stored_values: config.stored_values, enabled_tools: config.enabled_tools, next_tool_call_id: 1, + next_timeout_id: 1, tool_call_id: config.tool_call_id, + runtime_command_tx, exit_requested: false, }); @@ -223,6 +239,7 @@ fn run_runtime( let Ok(command) = command_rx.recv() else { break; }; + match command { RuntimeCommand::Terminate => break, RuntimeCommand::ToolResponse { id, result } => { @@ -241,6 +258,12 @@ fn run_runtime( return; } } + RuntimeCommand::TimeoutFired { id } => { + if let Err(runtime_error) = timers::invoke_timeout_callback(scope, id) { + capture_scope_send_error(scope, &event_tx, Some(runtime_error)); + return; + } + } } scope.perform_microtask_checkpoint(); diff --git a/codex-rs/code-mode/src/runtime/timers.rs b/codex-rs/code-mode/src/runtime/timers.rs new file mode 100644 index 0000000000..01c414cefe --- /dev/null +++ b/codex-rs/code-mode/src/runtime/timers.rs @@ -0,0 +1,114 @@ +use std::thread; +use std::time::Duration; + +use super::RuntimeCommand; +use super::RuntimeState; +use super::value::value_to_error_text; + +pub(super) struct ScheduledTimeout { + callback: v8::Global, +} + +pub(super) fn schedule_timeout( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, +) -> Result { + let callback = args.get(0); + if !callback.is_function() { + return Err("setTimeout expects a function callback".to_string()); + } + let callback = v8::Local::::try_from(callback) + .map_err(|_| "setTimeout expects a function callback".to_string())?; + + let delay_ms = args + .get(1) + .number_value(scope) + .map(normalize_delay_ms) + .unwrap_or(0); + + let callback = v8::Global::new(scope, callback); + let state = scope + .get_slot_mut::() + .ok_or_else(|| "runtime state unavailable".to_string())?; + let timeout_id = state.next_timeout_id; + state.next_timeout_id = state.next_timeout_id.saturating_add(1); + let runtime_command_tx = state.runtime_command_tx.clone(); + state + .pending_timeouts + .insert(timeout_id, ScheduledTimeout { callback }); + thread::spawn(move || { + thread::sleep(Duration::from_millis(delay_ms)); + let _ = runtime_command_tx.send(RuntimeCommand::TimeoutFired { id: timeout_id }); + }); + + Ok(timeout_id) +} + +pub(super) fn clear_timeout( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, +) -> Result<(), String> { + let Some(timeout_id) = timeout_id_from_args(scope, args)? else { + return Ok(()); + }; + + let Some(state) = scope.get_slot_mut::() else { + return Err("runtime state unavailable".to_string()); + }; + state.pending_timeouts.remove(&timeout_id); + Ok(()) +} + +pub(super) fn invoke_timeout_callback( + scope: &mut v8::PinScope<'_, '_>, + timeout_id: u64, +) -> Result<(), String> { + let callback = { + let state = scope + .get_slot_mut::() + .ok_or_else(|| "runtime state unavailable".to_string())?; + state.pending_timeouts.remove(&timeout_id) + }; + let Some(callback) = callback else { + return Ok(()); + }; + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let callback = v8::Local::new(&tc, &callback.callback); + let receiver = v8::undefined(&tc).into(); + let _ = callback.call(&tc, receiver, &[]); + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + + Ok(()) +} +fn timeout_id_from_args( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, +) -> Result, String> { + if args.length() == 0 || args.get(0).is_null_or_undefined() { + return Ok(None); + } + + let Some(timeout_id) = args.get(0).number_value(scope) else { + return Err("clearTimeout expects a numeric timeout id".to_string()); + }; + if !timeout_id.is_finite() || timeout_id <= 0.0 { + return Ok(None); + } + + Ok(Some(timeout_id.trunc().min(u64::MAX as f64) as u64)) +} + +fn normalize_delay_ms(delay_ms: f64) -> u64 { + if !delay_ms.is_finite() || delay_ms <= 0.0 { + 0 + } else { + delay_ms.trunc().min(u64::MAX as f64) as u64 + } +} diff --git a/codex-rs/codex-mcp/src/mcp/auth.rs b/codex-rs/codex-mcp/src/mcp/auth.rs index a4e14e42f9..01dc5e9b46 100644 --- a/codex-rs/codex-mcp/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use anyhow::Result; +use codex_config::types::OAuthCredentialsStoreMode; use codex_protocol::protocol::McpAuthStatus; -use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_rmcp_client::OAuthProviderError; use codex_rmcp_client::determine_streamable_http_auth_status; use codex_rmcp_client::discover_streamable_http_oauth; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index b843bbadc0..faa8ecc2fc 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -12,6 +12,7 @@ use async_channel::unbounded; use codex_config::Constrained; use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; +use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; use codex_protocol::mcp::Resource; @@ -20,7 +21,6 @@ use codex_protocol::mcp::Tool; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpListToolsResponseEvent; use codex_protocol::protocol::SandboxPolicy; -use codex_rmcp_client::OAuthCredentialsStoreMode; use serde_json::Value; use crate::mcp::auth::compute_auth_statuses; @@ -34,6 +34,19 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__"; pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum McpSnapshotDetail { + #[default] + Full, + ToolsAndAuthOnly, +} + +impl McpSnapshotDetail { + fn include_resources(self) -> bool { + matches!(self, Self::Full) + } +} + /// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`. /// MCP server/tool names are user-controlled, so sanitize the fully-qualified /// name we expose to the model by replacing any disallowed character with `_`. @@ -283,6 +296,15 @@ pub async fn collect_mcp_snapshot( config: &McpConfig, auth: Option<&CodexAuth>, submit_id: String, +) -> McpListToolsResponseEvent { + collect_mcp_snapshot_with_detail(config, auth, submit_id, McpSnapshotDetail::Full).await +} + +pub async fn collect_mcp_snapshot_with_detail( + config: &McpConfig, + auth: Option<&CodexAuth>, + submit_id: String, + detail: McpSnapshotDetail, ) -> McpListToolsResponseEvent { let mcp_servers = effective_mcp_servers(config, auth); let tool_plugin_provenance = tool_plugin_provenance(config); @@ -323,8 +345,12 @@ pub async fn collect_mcp_snapshot( ) .await; - let snapshot = - collect_mcp_snapshot_from_manager(&mcp_connection_manager, auth_status_entries).await; + let snapshot = collect_mcp_snapshot_from_manager_with_detail( + &mcp_connection_manager, + auth_status_entries, + detail, + ) + .await; cancel_token.cancel(); @@ -363,11 +389,36 @@ pub fn group_tools_by_server( pub async fn collect_mcp_snapshot_from_manager( mcp_connection_manager: &McpConnectionManager, auth_status_entries: HashMap, +) -> McpListToolsResponseEvent { + collect_mcp_snapshot_from_manager_with_detail( + mcp_connection_manager, + auth_status_entries, + McpSnapshotDetail::Full, + ) + .await +} + +pub async fn collect_mcp_snapshot_from_manager_with_detail( + mcp_connection_manager: &McpConnectionManager, + auth_status_entries: HashMap, + detail: McpSnapshotDetail, ) -> McpListToolsResponseEvent { let (tools, resources, resource_templates) = tokio::join!( mcp_connection_manager.list_all_tools(), - mcp_connection_manager.list_all_resources(), - mcp_connection_manager.list_all_resource_templates(), + async { + if detail.include_resources() { + mcp_connection_manager.list_all_resources().await + } else { + HashMap::new() + } + }, + async { + if detail.include_resources() { + mcp_connection_manager.list_all_resource_templates().await + } else { + HashMap::new() + } + }, ); let auth_statuses = auth_status_entries diff --git a/codex-rs/codex-mcp/src/mcp_connection_manager.rs b/codex-rs/codex-mcp/src/mcp_connection_manager.rs index 9a995a5c76..b4de70ee95 100644 --- a/codex-rs/codex-mcp/src/mcp_connection_manager.rs +++ b/codex-rs/codex-mcp/src/mcp_connection_manager.rs @@ -34,6 +34,7 @@ use async_channel::Sender; use codex_async_utils::CancelErr; use codex_async_utils::OrCancelExt; use codex_config::Constrained; +use codex_config::types::OAuthCredentialsStoreMode; use codex_protocol::approvals::ElicitationRequest; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::mcp::CallToolResult; @@ -47,7 +48,6 @@ use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::SandboxPolicy; use codex_rmcp_client::ElicitationResponse; -use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_rmcp_client::RmcpClient; use codex_rmcp_client::SendElicitation; use futures::future::BoxFuture; diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 0b440263eb..9532d74d00 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -8,10 +8,16 @@ license.workspace = true workspace = true [dependencies] +anyhow = { workspace = true } codex-app-server-protocol = { workspace = true } codex-execpolicy = { workspace = true } +codex-features = { workspace = true } +codex-git-utils = { workspace = true } +codex-model-provider-info = { workspace = true } +codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } +dunce = { workspace = true } futures = { workspace = true, features = ["alloc", "std"] } multimap = { workspace = true } schemars = { workspace = true } @@ -27,7 +33,6 @@ tracing = { workspace = true } wildmatch = { workspace = true } [dev-dependencies] -anyhow = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 7f6b5bc1e5..eb1c1033a5 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -237,6 +237,8 @@ pub struct NetworkRequirementsToml { /// When true, only managed `allowed_domains` are respected while managed /// network enforcement is active. User allowlist entries are ignored. pub managed_allowed_domains_only: Option, + /// In danger-full-access mode, allow all network access and enforce managed deny entries. + pub danger_full_access_denylist_only: Option, pub unix_sockets: Option, pub allow_local_binding: Option, } @@ -255,6 +257,8 @@ struct RawNetworkRequirementsToml { /// When true, only managed `allowed_domains` are respected while managed /// network enforcement is active. User allowlist entries are ignored. managed_allowed_domains_only: Option, + /// In danger-full-access mode, allow all network access and enforce managed deny entries. + danger_full_access_denylist_only: Option, #[serde(default)] denied_domains: Option>, unix_sockets: Option, @@ -279,6 +283,7 @@ impl<'de> Deserialize<'de> for NetworkRequirementsToml { domains, allowed_domains, managed_allowed_domains_only, + danger_full_access_denylist_only, denied_domains, unix_sockets, allow_unix_sockets, @@ -307,6 +312,7 @@ impl<'de> Deserialize<'de> for NetworkRequirementsToml { domains: domains .or_else(|| legacy_domain_permissions_from_lists(allowed_domains, denied_domains)), managed_allowed_domains_only, + danger_full_access_denylist_only, unix_sockets: unix_sockets .or_else(|| legacy_unix_socket_permissions_from_list(allow_unix_sockets)), allow_local_binding, @@ -359,6 +365,8 @@ pub struct NetworkConstraints { /// When true, only managed `allowed_domains` are respected while managed /// network enforcement is active. User allowlist entries are ignored. pub managed_allowed_domains_only: Option, + /// In danger-full-access mode, allow all network access and enforce managed deny entries. + pub danger_full_access_denylist_only: Option, pub unix_sockets: Option, pub allow_local_binding: Option, } @@ -384,6 +392,7 @@ impl From for NetworkConstraints { dangerously_allow_all_unix_sockets, domains, managed_allowed_domains_only, + danger_full_access_denylist_only, unix_sockets, allow_local_binding, } = value; @@ -396,6 +405,7 @@ impl From for NetworkConstraints { dangerously_allow_all_unix_sockets, domains, managed_allowed_domains_only, + danger_full_access_denylist_only, unix_sockets, allow_local_binding, } @@ -1808,6 +1818,7 @@ allowed_approvals_reviewers = ["user"] allow_upstream_proxy = false dangerously_allow_all_unix_sockets = true managed_allowed_domains_only = true + danger_full_access_denylist_only = true allow_local_binding = false [experimental_network.domains] @@ -1858,6 +1869,10 @@ allowed_approvals_reviewers = ["user"] sourced_network.value.managed_allowed_domains_only, Some(true) ); + assert_eq!( + sourced_network.value.danger_full_access_denylist_only, + Some(true) + ); assert_eq!( sourced_network.value.unix_sockets.as_ref(), Some(&NetworkUnixSocketPermissionsToml { @@ -1881,6 +1896,7 @@ allowed_approvals_reviewers = ["user"] dangerously_allow_all_unix_sockets = true allowed_domains = ["api.example.com", "*.openai.com"] managed_allowed_domains_only = true + danger_full_access_denylist_only = true denied_domains = ["blocked.example.com"] allow_unix_sockets = ["/tmp/example.sock"] allow_local_binding = false @@ -1925,6 +1941,10 @@ allowed_approvals_reviewers = ["user"] sourced_network.value.managed_allowed_domains_only, Some(true) ); + assert_eq!( + sourced_network.value.danger_full_access_denylist_only, + Some(true) + ); assert_eq!( sourced_network.value.unix_sockets.as_ref(), Some(&NetworkUnixSocketPermissionsToml { diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs new file mode 100644 index 0000000000..caf9d25b88 --- /dev/null +++ b/codex-rs/config/src/config_toml.rs @@ -0,0 +1,778 @@ +//! Schema-heavy configuration TOML types used by Codex. + +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::Path; + +use crate::permissions_toml::PermissionsToml; +use crate::profile_toml::ConfigProfile; +use crate::types::AnalyticsConfigToml; +use crate::types::ApprovalsReviewer; +use crate::types::AppsConfigToml; +use crate::types::AuthCredentialsStoreMode; +use crate::types::FeedbackConfigToml; +use crate::types::History; +use crate::types::McpServerConfig; +use crate::types::MemoriesToml; +use crate::types::Notice; +use crate::types::OAuthCredentialsStoreMode; +use crate::types::OtelConfigToml; +use crate::types::PluginConfig; +use crate::types::SandboxWorkspaceWrite; +use crate::types::ShellEnvironmentPolicyToml; +use crate::types::SkillsConfig; +use crate::types::ToolSuggestConfig; +use crate::types::Tui; +use crate::types::UriBasedFileOpener; +use crate::types::WindowsToml; +use codex_app_server_protocol::Tools; +use codex_app_server_protocol::UserSavedConfig; +use codex_features::FeaturesToml; +use codex_git_utils::resolve_root_git_project_for_trust; +use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; +use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; +use codex_model_provider_info::ModelProviderInfo; +use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; +use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; +use codex_model_provider_info::OPENAI_PROVIDER_ID; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WebSearchToolConfig; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; + +const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [ + OPENAI_PROVIDER_ID, + OLLAMA_OSS_PROVIDER_ID, + LMSTUDIO_OSS_PROVIDER_ID, +]; + +/// Base config deserialized from ~/.codex/config.toml. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ConfigToml { + /// Optional override of model selection. + pub model: Option, + /// Review model override used by the `/review` feature. + pub review_model: Option, + + /// Provider to use from the model_providers map. + pub model_provider: Option, + + /// Size of the context window for the model, in tokens. + pub model_context_window: Option, + + /// Token usage threshold triggering auto-compaction of conversation history. + pub model_auto_compact_token_limit: Option, + + /// Default approval policy for executing commands. + pub approval_policy: Option, + + /// Configures who approval requests are routed to for review once they have + /// been escalated. This does not disable separate safety checks such as + /// ARC. + pub approvals_reviewer: Option, + + #[serde(default)] + pub shell_environment_policy: ShellEnvironmentPolicyToml, + + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `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. + pub allow_login_shell: Option, + + /// Sandbox mode to use. + pub sandbox_mode: Option, + + /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. + pub sandbox_workspace_write: Option, + + /// Default named permissions profile to apply from the `[permissions]` + /// table. + pub default_permissions: Option, + + /// Named permissions profiles. + #[serde(default)] + pub permissions: Option, + + /// Optional external command to spawn for end-user notifications. + #[serde(default)] + pub notify: Option>, + + /// System instructions. + pub instructions: Option, + + /// Developer instructions inserted as a `developer` role message. + #[serde(default)] + pub developer_instructions: Option, + + /// Whether to inject the `` developer block. + pub include_permissions_instructions: Option, + + /// Whether to inject the `` developer block. + pub include_apps_instructions: Option, + + /// Whether to inject the `` user block. + pub include_environment_context: Option, + + /// Optional path to a file containing model instructions that will override + /// the built-in instructions for the selected model. Users are STRONGLY + /// DISCOURAGED from using this field, as deviating from the instructions + /// sanctioned by Codex will likely degrade model performance. + pub model_instructions_file: Option, + + /// Compact prompt used for history compaction. + pub compact_prompt: Option, + + /// Optional commit attribution text for commit message co-author trailers. + /// + /// Set to an empty string to disable automatic commit attribution. + pub commit_attribution: Option, + + /// When set, restricts ChatGPT login to a specific workspace identifier. + #[serde(default)] + pub forced_chatgpt_workspace_id: Option, + + /// When set, restricts the login mechanism users may use. + #[serde(default)] + pub forced_login_method: Option, + + /// Preferred backend for storing CLI auth credentials. + /// file (default): Use a file in the Codex home directory. + /// keyring: Use an OS-specific keyring service. + /// auto: Use the keyring if available, otherwise use a file. + #[serde(default)] + pub cli_auth_credentials_store: Option, + + /// Definition for MCP servers that Codex can reach out to for tool calls. + #[serde(default)] + // Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`. + #[schemars(schema_with = "crate::schema::mcp_servers_schema")] + pub mcp_servers: HashMap, + + /// Preferred backend for storing MCP OAuth credentials. + /// keyring: Use an OS-specific keyring service. + /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 + /// file: Use a file in the Codex home directory. + /// auto (default): Use the OS-specific keyring service if available, otherwise use a file. + #[serde(default)] + pub mcp_oauth_credentials_store: Option, + + /// Optional fixed port for the local HTTP callback server used during MCP OAuth login. + /// When unset, Codex will bind to an ephemeral port chosen by the OS. + pub mcp_oauth_callback_port: Option, + + /// Optional redirect URI to use during MCP OAuth login. + /// When set, this URI is used in the OAuth authorization request instead + /// of the local listener address. The local callback listener still binds + /// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided). + pub mcp_oauth_callback_url: Option, + + /// User-defined provider entries that extend the built-in list. Built-in + /// IDs cannot be overridden. + #[serde(default, deserialize_with = "deserialize_model_providers")] + pub model_providers: HashMap, + + /// Maximum number of bytes to include from an AGENTS.md project doc file. + pub project_doc_max_bytes: Option, + + /// Ordered list of fallback filenames to look for when AGENTS.md is missing. + pub project_doc_fallback_filenames: Option>, + + /// Token budget applied when storing tool/function outputs in the context manager. + pub tool_output_token_limit: Option, + + /// Maximum poll window for background terminal output (`write_stdin`), in milliseconds. + /// Default: `300000` (5 minutes). + pub background_terminal_max_timeout: Option, + + /// Optional absolute path to the Node runtime used by `js_repl`. + pub js_repl_node_path: Option, + + /// Ordered list of directories to search for Node modules in `js_repl`. + pub js_repl_node_module_dirs: Option>, + + /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. + pub zsh_path: Option, + + /// Profile to use from the `profiles` map. + pub profile: Option, + + /// Named profiles to facilitate switching between different configurations. + #[serde(default)] + pub profiles: HashMap, + + /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. + #[serde(default)] + pub history: Option, + + /// Directory where Codex stores the SQLite state DB. + /// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`. + pub sqlite_home: Option, + + /// Directory where Codex writes log files, for example `codex-tui.log`. + /// Defaults to `$CODEX_HOME/log`. + pub log_dir: Option, + + /// 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, + + /// Collection of settings that are specific to the TUI. + pub tui: Option, + + /// When set to `true`, `AgentReasoning` events will be hidden from the + /// UI/output. Defaults to `false`. + pub hide_agent_reasoning: Option, + + /// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. + /// Defaults to `false`. + pub show_raw_agent_reasoning: Option, + + pub model_reasoning_effort: Option, + pub plan_mode_reasoning_effort: Option, + pub model_reasoning_summary: Option, + /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). + pub model_verbosity: Option, + + /// Override to force-enable reasoning summaries for the configured model. + pub model_supports_reasoning_summaries: Option, + + /// Optional path to a JSON model catalog (applied on startup only). + /// Per-thread `config` overrides are accepted but do not reapply this (no-ops). + pub model_catalog_json: Option, + + /// Optionally specify a personality for the model + pub personality: Option, + + /// Optional explicit service tier preference for new turns (`fast` or `flex`). + pub service_tier: Option, + + /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). + pub chatgpt_base_url: Option, + + /// Base URL override for the built-in `openai` model provider. + pub openai_base_url: Option, + + /// Machine-local realtime audio device preferences used by realtime voice. + #[serde(default)] + pub audio: Option, + + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport base URL (the `Op::RealtimeConversation` + /// `/v1/realtime` + /// connection) without changing normal provider HTTP requests. + pub experimental_realtime_ws_base_url: Option, + /// Experimental / do not use. Selects the realtime websocket model/snapshot + /// used for the `Op::RealtimeConversation` connection. + pub experimental_realtime_ws_model: Option, + /// Experimental / do not use. Realtime websocket session selection. + /// `version` controls v1/v2 and `type` controls conversational/transcription. + #[serde(default)] + pub realtime: Option, + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport instructions (the `Op::RealtimeConversation` + /// `/ws` session.update instructions) without changing normal prompts. + pub experimental_realtime_ws_backend_prompt: Option, + /// Experimental / do not use. Replaces the synthesized realtime startup + /// context appended to websocket session instructions. An empty string + /// disables startup context injection entirely. + pub experimental_realtime_ws_startup_context: Option, + /// Experimental / do not use. Replaces the built-in realtime start + /// instructions inserted into developer messages when realtime becomes + /// active. + pub experimental_realtime_start_instructions: Option, + pub projects: Option>, + + /// Controls the web search tool mode: disabled, cached, or live. + pub web_search: Option, + + /// Nested tools section for feature toggles + pub tools: Option, + + /// Additional discoverable tools that can be suggested for installation. + pub tool_suggest: Option, + + /// Agent-related settings (thread limits, etc.). + pub agents: Option, + + /// Memories subsystem settings. + pub memories: Option, + + /// User-level skill config entries keyed by SKILL.md path. + pub skills: Option, + + /// User-level plugin config entries keyed by plugin name. + #[serde(default)] + pub plugins: HashMap, + + /// Centralized feature flags (new). Prefer this over individual toggles. + #[serde(default)] + // Injects known feature keys into the schema and forbids unknown keys. + #[schemars(schema_with = "crate::schema::features_schema")] + pub features: Option, + + /// Suppress warnings about unstable (under development) features. + pub suppress_unstable_features_warning: Option, + + /// Settings for ghost snapshots (used for undo). + #[serde(default)] + pub ghost_snapshot: Option, + + /// Markers used to detect the project root when searching parent + /// directories for `.codex` folders. Defaults to [".git"] when unset. + #[serde(default)] + pub project_root_markers: Option>, + + /// When `true`, checks for Codex updates on startup and surfaces update prompts. + /// Set to `false` only if your Codex updates are centrally managed. + /// Defaults to `true`. + pub check_for_update_on_startup: Option, + + /// When true, disables burst-paste detection for typed input entirely. + /// All characters are inserted as they are received, and no buffering + /// or placeholder replacement will occur for fast keypress bursts. + pub disable_paste_burst: Option, + + /// When `false`, disables analytics across Codex product surfaces in this machine. + /// Defaults to `true`. + pub analytics: Option, + + /// When `false`, disables feedback collection across Codex product surfaces. + /// Defaults to `true`. + pub feedback: Option, + + /// Settings for app-specific controls. + #[serde(default)] + pub apps: Option, + + /// OTEL configuration. + pub otel: Option, + + /// Windows-specific configuration. + #[serde(default)] + pub windows: Option, + + /// Tracks whether the Windows onboarding screen has been acknowledged. + pub windows_wsl_setup_acknowledged: Option, + + /// Collection of in-product notices (different from notifications) + /// See [`crate::types::Notice`] for more details + pub notice: Option, + + /// Legacy, now use features + /// Deprecated: ignored. Use `model_instructions_file`. + #[schemars(skip)] + pub experimental_instructions_file: Option, + pub experimental_compact_prompt_file: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_freeform_apply_patch: Option, + /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". + pub oss_provider: Option, +} + +impl From for UserSavedConfig { + fn from(config_toml: ConfigToml) -> Self { + let profiles = config_toml + .profiles + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect(); + + Self { + approval_policy: config_toml.approval_policy, + sandbox_mode: config_toml.sandbox_mode, + sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), + forced_chatgpt_workspace_id: config_toml.forced_chatgpt_workspace_id, + forced_login_method: config_toml.forced_login_method, + model: config_toml.model, + model_reasoning_effort: config_toml.model_reasoning_effort, + model_reasoning_summary: config_toml.model_reasoning_summary, + model_verbosity: config_toml.model_verbosity, + tools: config_toml.tools.map(From::from), + profile: config_toml.profile, + profiles, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ProjectConfig { + pub trust_level: Option, +} + +impl ProjectConfig { + pub fn is_trusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Trusted)) + } + + pub fn is_untrusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Untrusted)) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RealtimeAudioConfig { + pub microphone: Option, + pub speaker: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RealtimeWsMode { + #[default] + Conversational, + Transcription, +} + +pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeConfig { + pub version: RealtimeWsVersion, + #[serde(rename = "type")] + pub session_type: RealtimeWsMode, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeToml { + pub version: Option, + #[serde(rename = "type")] + pub session_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeAudioToml { + pub microphone: Option, + pub speaker: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolsToml { + #[serde( + default, + deserialize_with = "deserialize_optional_web_search_tool_config" + )] + pub web_search: Option, + + /// Enable the `view_image` tool that lets the agent attach local images. + #[serde(default)] + pub view_image: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum WebSearchToolConfigInput { + Enabled(bool), + Config(WebSearchToolConfig), +} + +fn deserialize_optional_web_search_tool_config<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + + Ok(match value { + None => None, + Some(WebSearchToolConfigInput::Enabled(enabled)) => { + let _ = enabled; + None + } + Some(WebSearchToolConfigInput::Config(config)) => Some(config), + }) +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentsToml { + /// Maximum number of agent threads that can be open concurrently. + /// When unset, no limit is enforced. + #[schemars(range(min = 1))] + pub max_threads: Option, + /// Maximum nesting depth allowed for spawned agent threads. + /// Root sessions start at depth 0. + #[schemars(range(min = 1))] + pub max_depth: Option, + /// Default maximum runtime in seconds for agent job workers. + #[schemars(range(min = 1))] + pub job_max_runtime_seconds: Option, + + /// User-defined role declarations keyed by role name. + /// + /// Example: + /// ```toml + /// [agents.researcher] + /// description = "Research-focused role." + /// config_file = "./agents/researcher.toml" + /// nickname_candidates = ["Herodotus", "Ibn Battuta"] + /// ``` + #[serde(default, flatten)] + pub roles: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentRoleToml { + /// Human-facing role documentation used in spawn tool guidance. + /// Required unless supplied by the referenced agent role file. + pub description: Option, + + /// Path to a role-specific config layer. + /// Relative paths are resolved relative to the `config.toml` that defines them. + pub config_file: Option, + + /// Candidate nicknames for agents spawned with this role. + pub nickname_candidates: Option>, +} + +impl From for Tools { + fn from(tools_toml: ToolsToml) -> Self { + Self { + web_search: tools_toml.web_search.is_some().then_some(true), + view_image: tools_toml.view_image, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct GhostSnapshotToml { + /// Exclude untracked files larger than this many bytes from ghost snapshots. + #[serde(alias = "ignore_untracked_files_over_bytes")] + pub ignore_large_untracked_files: Option, + /// Ignore untracked directories that contain this many files or more. + /// (Still emits a warning unless warnings are disabled.) + #[serde(alias = "large_untracked_dir_warning_threshold")] + pub ignore_large_untracked_dirs: Option, + /// Disable all ghost snapshot warning events. + pub disable_warnings: Option, +} + +impl ConfigToml { + /// Derive the effective sandbox policy from the configuration. + pub fn derive_sandbox_policy( + &self, + sandbox_mode_override: Option, + profile_sandbox_mode: Option, + windows_sandbox_level: WindowsSandboxLevel, + resolved_cwd: &Path, + sandbox_policy_constraint: Option<&crate::Constrained>, + ) -> SandboxPolicy { + let sandbox_mode_was_explicit = sandbox_mode_override.is_some() + || profile_sandbox_mode.is_some() + || self.sandbox_mode.is_some(); + let resolved_sandbox_mode = sandbox_mode_override + .or(profile_sandbox_mode) + .or(self.sandbox_mode) + .or_else(|| { + // If no sandbox_mode is set but this directory has a trust decision, + // default to workspace-write except on unsandboxed Windows where we + // default to read-only. + self.get_active_project(resolved_cwd).and_then(|p| { + if p.is_trusted() || p.is_untrusted() { + if cfg!(target_os = "windows") + && windows_sandbox_level == WindowsSandboxLevel::Disabled + { + Some(SandboxMode::ReadOnly) + } else { + Some(SandboxMode::WorkspaceWrite) + } + } else { + None + } + }) + }) + .unwrap_or_default(); + let mut sandbox_policy = match resolved_sandbox_mode { + SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(), + SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { + Some(SandboxWorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }) => SandboxPolicy::WorkspaceWrite { + writable_roots: writable_roots.clone(), + read_only_access: ReadOnlyAccess::FullAccess, + network_access: *network_access, + exclude_tmpdir_env_var: *exclude_tmpdir_env_var, + exclude_slash_tmp: *exclude_slash_tmp, + }, + None => SandboxPolicy::new_workspace_write_policy(), + }, + SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess, + }; + let downgrade_workspace_write_if_unsupported = |policy: &mut SandboxPolicy| { + if cfg!(target_os = "windows") + // If the experimental Windows sandbox is enabled, do not force a downgrade. + && windows_sandbox_level == WindowsSandboxLevel::Disabled + && matches!(&*policy, SandboxPolicy::WorkspaceWrite { .. }) + { + *policy = SandboxPolicy::new_read_only_policy(); + } + }; + if matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) { + downgrade_workspace_write_if_unsupported(&mut sandbox_policy); + } + if !sandbox_mode_was_explicit + && let Some(constraint) = sandbox_policy_constraint + && let Err(err) = constraint.can_set(&sandbox_policy) + { + tracing::warn!( + error = %err, + "default sandbox policy is disallowed by requirements; falling back to required default" + ); + sandbox_policy = constraint.get().clone(); + downgrade_workspace_write_if_unsupported(&mut sandbox_policy); + } + sandbox_policy + } + + /// Resolves the cwd to an existing project, or returns None if ConfigToml + /// does not contain a project corresponding to cwd or a git repo for cwd + pub fn get_active_project(&self, resolved_cwd: &Path) -> Option { + let projects = self.projects.clone().unwrap_or_default(); + + let resolved_cwd_key = project_trust_key(resolved_cwd); + let resolved_cwd_raw_key = resolved_cwd.to_string_lossy().to_string(); + if let Some(project_config) = projects + .get(&resolved_cwd_key) + .or_else(|| projects.get(&resolved_cwd_raw_key)) + { + return Some(project_config.clone()); + } + + // If cwd lives inside a git repo/worktree, check whether the root git project + // (the primary repository working directory) is trusted. This lets + // worktrees inherit trust from the main project. + if let Some(repo_root) = resolve_root_git_project_for_trust(resolved_cwd) { + let repo_root_key = project_trust_key(repo_root.as_path()); + let repo_root_raw_key = repo_root.to_string_lossy().to_string(); + if let Some(project_config_for_root) = projects + .get(&repo_root_key) + .or_else(|| projects.get(&repo_root_raw_key)) + { + return Some(project_config_for_root.clone()); + } + } + + None + } + + pub fn get_config_profile( + &self, + override_profile: Option, + ) -> Result { + let profile = override_profile.or_else(|| self.profile.clone()); + + match profile { + Some(key) => { + if let Some(profile) = self.profiles.get(key.as_str()) { + return Ok(profile.clone()); + } + + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("config profile `{key}` not found"), + )) + } + None => Ok(ConfigProfile::default()), + } + } +} + +/// Canonicalize the path and convert it to a string to be used as a key in the +/// projects trust map. On Windows, strips UNC, when possible, to try to ensure +/// that different paths that point to the same location have the same key. +fn project_trust_key(project_path: &Path) -> String { + dunce::canonicalize(project_path) + .unwrap_or_else(|_| project_path.to_path_buf()) + .to_string_lossy() + .to_string() +} + +pub fn validate_reserved_model_provider_ids( + model_providers: &HashMap, +) -> Result<(), String> { + let mut conflicts = model_providers + .keys() + .filter(|key| RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str())) + .map(|key| format!("`{key}`")) + .collect::>(); + conflicts.sort_unstable(); + if conflicts.is_empty() { + Ok(()) + } else { + Err(format!( + "model_providers contains reserved built-in provider IDs: {}. \ +Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).", + conflicts.join(", ") + )) + } +} + +pub fn validate_model_providers( + model_providers: &HashMap, +) -> Result<(), String> { + validate_reserved_model_provider_ids(model_providers)?; + for (key, provider) in model_providers { + provider + .validate() + .map_err(|message| format!("model_providers.{key}: {message}"))?; + } + Ok(()) +} + +fn deserialize_model_providers<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let model_providers = HashMap::::deserialize(deserializer)?; + validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?; + Ok(model_providers) +} + +pub fn validate_oss_provider(provider: &str) -> std::io::Result<()> { + match provider { + LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => Ok(()), + LEGACY_OLLAMA_CHAT_PROVIDER_ID => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + OLLAMA_CHAT_PROVIDER_REMOVED_ERROR, + )), + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}" + ), + )), + } +} diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 2196011323..5b8e5e6878 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,5 +1,6 @@ mod cloud_requirements; mod config_requirements; +pub mod config_toml; mod constraint; mod diagnostics; mod fingerprint; @@ -7,8 +8,11 @@ mod mcp_edit; mod mcp_types; mod merge; mod overrides; +pub mod permissions_toml; +pub mod profile_toml; mod project_root_markers; mod requirements_exec_policy; +pub mod schema; mod skills_config; mod state; pub mod types; diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs new file mode 100644 index 0000000000..fcc3e006b1 --- /dev/null +++ b/codex-rs/config/src/permissions_toml.rs @@ -0,0 +1,240 @@ +use std::collections::BTreeMap; + +use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission; +use codex_network_proxy::NetworkMode; +use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; +use codex_network_proxy::normalize_host; +use codex_protocol::permissions::FileSystemAccessMode; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct PermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl PermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PermissionProfileToml { + pub filesystem: Option, + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct FilesystemPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl FilesystemPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum FilesystemPermissionToml { + Access(FileSystemAccessMode), + Scoped(BTreeMap), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkDomainPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allowed_domains(&self) -> Option> { + let allowed_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!allowed_domains.is_empty()).then_some(allowed_domains) + } + + pub fn denied_domains(&self) -> Option> { + let denied_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!denied_domains.is_empty()).then_some(denied_domains) + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkDomainPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkUnixSocketPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allow_unix_sockets(&self) -> Vec { + self.entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) + .map(|(path, _)| path.clone()) + .collect() + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + None, +} + +impl std::fmt::Display for NetworkUnixSocketPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::None => "none", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkToml { + pub enabled: Option, + pub proxy_url: Option, + pub enable_socks5: Option, + pub socks_url: Option, + pub enable_socks5_udp: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + #[schemars(with = "Option")] + pub mode: Option, + pub domains: Option, + pub unix_sockets: Option, + pub allow_local_binding: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum NetworkModeSchema { + Limited, + Full, +} + +impl NetworkToml { + pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { + if let Some(enabled) = self.enabled { + config.network.enabled = enabled; + } + if let Some(proxy_url) = self.proxy_url.as_ref() { + config.network.proxy_url = proxy_url.clone(); + } + if let Some(enable_socks5) = self.enable_socks5 { + config.network.enable_socks5 = enable_socks5; + } + if let Some(socks_url) = self.socks_url.as_ref() { + config.network.socks_url = socks_url.clone(); + } + if let Some(enable_socks5_udp) = self.enable_socks5_udp { + config.network.enable_socks5_udp = enable_socks5_udp; + } + if let Some(allow_upstream_proxy) = self.allow_upstream_proxy { + config.network.allow_upstream_proxy = allow_upstream_proxy; + } + if let Some(dangerously_allow_non_loopback_proxy) = + self.dangerously_allow_non_loopback_proxy + { + config.network.dangerously_allow_non_loopback_proxy = + dangerously_allow_non_loopback_proxy; + } + if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets { + config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; + } + if let Some(mode) = self.mode { + config.network.mode = mode; + } + if let Some(domains) = self.domains.as_ref() { + overlay_network_domain_permissions(config, domains); + } + if let Some(unix_sockets) = self.unix_sockets.as_ref() { + let mut proxy_unix_sockets = config.network.unix_sockets.take().unwrap_or_default(); + for (path, permission) in &unix_sockets.entries { + let permission = match permission { + NetworkUnixSocketPermissionToml::Allow => { + ProxyNetworkUnixSocketPermission::Allow + } + NetworkUnixSocketPermissionToml::None => ProxyNetworkUnixSocketPermission::None, + }; + proxy_unix_sockets.entries.insert(path.clone(), permission); + } + config.network.unix_sockets = + (!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets); + } + if let Some(allow_local_binding) = self.allow_local_binding { + config.network.allow_local_binding = allow_local_binding; + } + } + + pub fn to_network_proxy_config(&self) -> NetworkProxyConfig { + let mut config = NetworkProxyConfig::default(); + self.apply_to_network_proxy_config(&mut config); + config + } +} + +pub fn overlay_network_domain_permissions( + config: &mut NetworkProxyConfig, + domains: &NetworkDomainPermissionsToml, +) { + for (pattern, permission) in &domains.entries { + let permission = match permission { + NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow, + NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny, + }; + config + .network + .upsert_domain_permission(pattern.clone(), permission, normalize_host); + } +} diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/config/src/profile_toml.rs similarity index 92% rename from codex-rs/core/src/config/profile.rs rename to codex-rs/config/src/profile_toml.rs index 7a83428b89..69215c0440 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -3,10 +3,11 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; -use crate::config::ToolsToml; -use codex_config::types::ApprovalsReviewer; -use codex_config::types::Personality; -use codex_config::types::WindowsToml; +use crate::config_toml::ToolsToml; +use crate::types::AnalyticsConfigToml; +use crate::types::ApprovalsReviewer; +use crate::types::Personality; +use crate::types::WindowsToml; use codex_features::FeaturesToml; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -57,13 +58,13 @@ pub struct ConfigProfile { pub tools_view_image: Option, pub tools: Option, pub web_search: Option, - pub analytics: Option, + pub analytics: Option, #[serde(default)] pub windows: Option, /// Optional feature toggles scoped to this profile. #[serde(default)] // Injects known feature keys into the schema and forbids unknown keys. - #[schemars(schema_with = "crate::config::schema::features_schema")] + #[schemars(schema_with = "crate::schema::features_schema")] pub features: Option, pub oss_provider: Option, } diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs new file mode 100644 index 0000000000..72252d7a21 --- /dev/null +++ b/codex-rs/config/src/schema.rs @@ -0,0 +1,100 @@ +use crate::config_toml::ConfigToml; +use crate::types::RawMcpServerConfig; +use codex_features::FEATURES; +use codex_features::legacy_feature_keys; +use schemars::r#gen::SchemaGenerator; +use schemars::r#gen::SchemaSettings; +use schemars::schema::InstanceType; +use schemars::schema::ObjectValidation; +use schemars::schema::RootSchema; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use serde_json::Map; +use serde_json::Value; +use std::path::Path; + +/// Schema for the `[features]` map with known + legacy keys only. +pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let mut validation = ObjectValidation::default(); + for feature in FEATURES { + if feature.id == codex_features::Feature::Artifact { + continue; + } + validation + .properties + .insert(feature.key.to_string(), schema_gen.subschema_for::()); + } + for legacy_key in legacy_feature_keys() { + validation + .properties + .insert(legacy_key.to_string(), schema_gen.subschema_for::()); + } + validation.additional_properties = Some(Box::new(Schema::Bool(false))); + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} + +/// Schema for the `[mcp_servers]` map using the raw input shape. +pub fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let validation = ObjectValidation { + additional_properties: Some(Box::new(schema_gen.subschema_for::())), + ..Default::default() + }; + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} + +/// Build the config schema for `config.toml`. +pub fn config_schema() -> RootSchema { + SchemaSettings::draft07() + .with(|settings| { + settings.option_add_null_type = false; + }) + .into_generator() + .into_root_schema_for::() +} + +/// Canonicalize a JSON value by sorting its keys. +pub fn canonicalize(value: &Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), + Value::Object(map) => { + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let mut sorted = Map::with_capacity(map.len()); + for (key, child) in entries { + sorted.insert(key.clone(), canonicalize(child)); + } + Value::Object(sorted) + } + _ => value.clone(), + } +} + +/// Render the config schema as pretty-printed JSON. +pub fn config_schema_json() -> anyhow::Result> { + let schema = config_schema(); + let value = serde_json::to_value(schema)?; + let value = canonicalize(&value); + let json = serde_json::to_vec_pretty(&value)?; + Ok(json) +} + +/// Write the config schema fixture to disk. +pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> { + let json = config_schema_json()?; + std::fs::write(out_path, json)?; + Ok(()) +} diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index c523833525..f26d336018 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -36,6 +36,36 @@ const fn default_enabled() -> bool { true } +/// Determine where Codex should store CLI auth credentials. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum AuthCredentialsStoreMode { + #[default] + /// Persist credentials in CODEX_HOME/auth.json. + File, + /// Persist credentials in the keyring. Fail if unavailable. + Keyring, + /// Use keyring when available; otherwise, fall back to a file in CODEX_HOME. + Auto, + /// Store credentials in memory only for the current process. + Ephemeral, +} + +/// Determine where Codex should store and read MCP credentials. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum OAuthCredentialsStoreMode { + /// `Keyring` when available; otherwise, `File`. + /// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access. + #[default] + Auto, + /// CODEX_HOME/.credentials.json + /// This file will be readable to Codex and other applications running as the same user. + File, + /// Keyring when available, otherwise fail. + Keyring, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum WindowsSandboxModeToml { diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 0a9dfb521e..d2cbad83ef 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -93,7 +93,6 @@ rmcp = { workspace = true, default-features = false, features = [ "schemars", "server", ] } -schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha1 = { workspace = true } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 63cd3b6f36..d8ea2ac984 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -41,7 +41,8 @@ too old to support `--argv0`, the helper keeps using system bubblewrap and switches to a no-`--argv0` compatibility path for the inner re-exec. If `bwrap` is missing, it falls back to the vendored bubblewrap path compiled into the binary and Codex surfaces a startup warning through its normal notification -path instead of printing directly from the sandbox helper. +path instead of printing directly from the sandbox helper. Codex also surfaces +a startup warning when bubblewrap cannot create user namespaces. ### Windows diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7f5be535ea..8da02dc803 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -437,6 +437,9 @@ "realtime_conversation": { "type": "boolean" }, + "remote_control": { + "type": "boolean" + }, "remote_models": { "type": "boolean" }, @@ -2144,6 +2147,9 @@ "realtime_conversation": { "type": "boolean" }, + "remote_control": { + "type": "boolean" + }, "remote_models": { "type": "boolean" }, @@ -2417,7 +2423,7 @@ "$ref": "#/definitions/Notice" } ], - "description": "Collection of in-product notices (different from notifications) See [`codex_config::types::Notice`] for more details" + "description": "Collection of in-product notices (different from notifications) See [`crate::types::Notice`] for more details" }, "notify": { "default": null, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d254a49343..a64a11bf98 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -256,6 +256,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -267,6 +268,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -288,6 +291,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1200,6 +1205,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1284,6 +1291,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index b7d7b55ab1..6570baa45a 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -17,6 +17,7 @@ use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::resolve_relative_paths_in_config_toml; use anyhow::anyhow; use codex_app_server_protocol::ConfigLayerSource; +use codex_config::config_toml::ConfigToml; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::path::Path; @@ -221,7 +222,7 @@ mod reload { fn deserialize_effective_config( config: &Config, config_layer_stack: &ConfigLayerStack, - ) -> anyhow::Result { + ) -> anyhow::Result { Ok(deserialize_config_toml_with_base( config_layer_stack.effective_config(), &config.codex_home, diff --git a/codex-rs/core/src/bin/config_schema.rs b/codex-rs/core/src/bin/config_schema.rs index 8d33df42e1..f92ce62307 100644 --- a/codex-rs/core/src/bin/config_schema.rs +++ b/codex-rs/core/src/bin/config_schema.rs @@ -15,6 +15,6 @@ fn main() -> Result<()> { let out_path = args .out .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.schema.json")); - codex_core::config::schema::write_config_schema(&out_path)?; + codex_config::schema::write_config_schema(&out_path)?; Ok(()) } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..4bf309c902 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -54,6 +54,7 @@ use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_config::types::OAuthCredentialsStoreMode; use codex_exec_server::Environment; use codex_exec_server::EnvironmentManager; use codex_features::FEATURES; @@ -128,7 +129,6 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; -use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_rollout::state_db; use codex_shell_command::parse_command::parse_command; use codex_terminal_detection::user_agent; @@ -862,7 +862,7 @@ pub(crate) struct TurnContext { pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, - pub(crate) environment: Arc, + pub(crate) environment: Option>, /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. @@ -905,8 +905,13 @@ impl TurnContext { } pub(crate) fn apps_enabled(&self) -> bool { - self.features - .apps_enabled_cached(self.auth_manager.as_deref()) + let is_chatgpt_auth = self + .auth_manager + .as_deref() + .and_then(AuthManager::auth_cached) + .as_ref() + .is_some_and(CodexAuth::is_chatgpt_auth); + self.features.apps_enabled_for_auth(is_chatgpt_auth) } pub(crate) async fn with_model(&self, model: String, models_manager: &ModelsManager) -> Self { @@ -958,6 +963,7 @@ impl TurnContext { .with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone()) .with_web_search_config(self.tools_config.web_search_config.clone()) .with_allow_login_shell(self.tools_config.allow_login_shell) + .with_has_environment(self.tools_config.has_environment) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &config.agent_roles, )); @@ -977,7 +983,7 @@ impl TurnContext { reasoning_effort, reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), - environment: Arc::clone(&self.environment), + environment: self.environment.clone(), cwd: self.cwd.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -1406,7 +1412,7 @@ impl Session { model_info: ModelInfo, models_manager: &ModelsManager, network: Option, - environment: Arc, + environment: Option>, sub_id: String, js_repl: Arc, skills_outcome: Arc, @@ -1439,6 +1445,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_agent_type_description(crate::agent::role::spawn_tool_spec::build( &per_turn_config.agent_roles, )); @@ -2542,7 +2549,7 @@ impl Session { .network_proxy .as_ref() .map(StartedNetworkProxy::proxy), - Arc::clone(&self.services.environment), + self.services.environment.clone(), sub_id, Arc::clone(&self.js_repl), skills_outcome, @@ -5614,6 +5621,7 @@ async fn spawn_review_thread( ) .with_web_search_config(/*web_search_config*/ None) .with_allow_login_shell(config.permissions.allow_login_shell) + .with_has_environment(parent_turn_context.environment.is_some()) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &config.agent_roles, )); @@ -5672,7 +5680,7 @@ async fn spawn_review_thread( reasoning_effort, reasoning_summary, session_source, - environment: Arc::clone(&parent_turn_context.environment), + environment: parent_turn_context.environment.clone(), tools_config, features: parent_turn_context.features.clone(), ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..753244ac2b 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..ab6324efa2 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -78,8 +78,8 @@ pub(crate) async fn run_codex_thread_interactive( config, auth_manager, models_manager, - environment_manager: Arc::new(EnvironmentManager::new( - parent_ctx.environment.exec_server_url().map(str::to_owned), + environment_manager: Arc::new(EnvironmentManager::from_environment( + parent_ctx.environment.as_deref(), )), skills_manager: Arc::clone(&parent_session.services.skills_manager), plugins_manager: Arc::clone(&parent_session.services.plugins_manager), diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 85404d4d56..88a8902f85 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -235,13 +235,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1300,6 +1306,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1317,6 +1324,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1481,6 +1490,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1499,10 +1509,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1521,6 +1534,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1579,6 +1594,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1595,10 +1611,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1610,10 +1629,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1634,6 +1656,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1661,6 +1685,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1677,10 +1702,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1697,10 +1725,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1717,6 +1748,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -2737,7 +2770,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment: Arc::clone(&environment), + environment: Some(Arc::clone(&environment)), }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), @@ -2764,7 +2797,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { model_info, &models_manager, /*network*/ None, - environment, + Some(environment), "turn_id".to_string(), Arc::clone(&js_repl), skills_outcome, @@ -3577,7 +3610,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment: Arc::clone(&environment), + environment: Some(Arc::clone(&environment)), }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), @@ -3604,7 +3637,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( model_info, &models_manager, /*network*/ None, - environment, + Some(environment), "turn_id".to_string(), Arc::clone(&js_repl), skills_outcome, @@ -4624,6 +4657,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..9727cc208a 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -22,6 +22,7 @@ use codex_protocol::protocol::Submission; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::UserInput; +use rmcp::model::ReadResourceRequestParams; use std::path::PathBuf; use tokio::sync::Mutex; use tokio::sync::watch; @@ -199,6 +200,26 @@ impl CodexThread { self.codex.thread_config_snapshot().await } + pub async fn read_mcp_resource( + &self, + server: &str, + uri: &str, + ) -> anyhow::Result { + let result = self + .codex + .session + .read_resource( + server, + ReadResourceRequestParams { + meta: None, + uri: uri.to_string(), + }, + ) + .await?; + + Ok(serde_json::to_value(result)?) + } + pub fn enabled(&self, feature: Feature) -> bool { self.codex.enabled(feature) } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..5bc7944d28 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/config/agent_roles.rs b/codex-rs/core/src/config/agent_roles.rs index c527435e92..24d26ebf47 100644 --- a/codex-rs/core/src/config/agent_roles.rs +++ b/codex-rs/core/src/config/agent_roles.rs @@ -1,9 +1,9 @@ use super::AgentRoleConfig; -use super::AgentRoleToml; -use super::AgentsToml; -use super::ConfigToml; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; +use codex_config::config_toml::AgentRoleToml; +use codex_config::config_toml::AgentsToml; +use codex_config::config_toml::ConfigToml; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use serde::Deserialize; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 4b1024d683..d356949fc9 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5,6 +5,24 @@ use crate::config_loader::RequirementSource; use crate::plugins::PluginsManager; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_config::config_toml::AgentRoleToml; +use codex_config::config_toml::AgentsToml; +use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::ProjectConfig; +use codex_config::config_toml::RealtimeAudioConfig; +use codex_config::config_toml::RealtimeConfig; +use codex_config::config_toml::RealtimeToml; +use codex_config::config_toml::RealtimeWsMode; +use codex_config::config_toml::RealtimeWsVersion; +use codex_config::config_toml::ToolsToml; +use codex_config::permissions_toml::FilesystemPermissionToml; +use codex_config::permissions_toml::FilesystemPermissionsToml; +use codex_config::permissions_toml::NetworkDomainPermissionToml; +use codex_config::permissions_toml::NetworkDomainPermissionsToml; +use codex_config::permissions_toml::NetworkToml; +use codex_config::permissions_toml::PermissionProfileToml; +use codex_config::permissions_toml::PermissionsToml; +use codex_config::profile_toml::ConfigProfile; use codex_config::types::AppToolApproval; use codex_config::types::ApprovalsReviewer; use codex_config::types::BundledSkillsConfig; @@ -17,9 +35,14 @@ use codex_config::types::MemoriesToml; use codex_config::types::ModelAvailabilityNuxConfig; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; +use codex_config::types::SandboxWorkspaceWrite; +use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestDiscoverableType; +use codex_config::types::Tui; use codex_features::Feature; use codex_features::FeaturesToml; +use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; +use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; use codex_protocol::permissions::FileSystemAccessMode; @@ -28,6 +51,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::ReadOnlyAccess; use serde::Deserialize; use tempfile::tempdir; diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index 44daa241cd..cbad012c6c 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -8,8 +8,8 @@ use codex_config::FeatureRequirementsToml; use codex_config::RequirementSource; use codex_config::Sourced; -use crate::config::ConfigToml; -use crate::config::profile::ConfigProfile; +use codex_config::config_toml::ConfigToml; +use codex_config::profile_toml::ConfigProfile; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 20eb99570a..ff0e6c0dab 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -22,49 +22,42 @@ use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS; use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; use crate::windows_sandbox::resolve_windows_sandbox_private_desktop; -use codex_app_server_protocol::Tools; -use codex_app_server_protocol::UserSavedConfig; +use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::ProjectConfig; +use codex_config::config_toml::RealtimeAudioConfig; +use codex_config::config_toml::RealtimeConfig; +use codex_config::config_toml::validate_model_providers; +use codex_config::profile_toml::ConfigProfile; use codex_config::types::ApprovalsReviewer; -use codex_config::types::AppsConfigToml; +use codex_config::types::AuthCredentialsStoreMode; use codex_config::types::DEFAULT_OTEL_ENVIRONMENT; use codex_config::types::History; use codex_config::types::McpServerConfig; use codex_config::types::McpServerDisabledReason; use codex_config::types::McpServerTransportConfig; use codex_config::types::MemoriesConfig; -use codex_config::types::MemoriesToml; use codex_config::types::ModelAvailabilityNuxConfig; use codex_config::types::Notice; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; +use codex_config::types::OAuthCredentialsStoreMode; use codex_config::types::OtelConfig; use codex_config::types::OtelConfigToml; use codex_config::types::OtelExporterKind; -use codex_config::types::PluginConfig; -use codex_config::types::SandboxWorkspaceWrite; use codex_config::types::ShellEnvironmentPolicy; -use codex_config::types::ShellEnvironmentPolicyToml; -use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDiscoverable; -use codex_config::types::Tui; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; -use codex_config::types::WindowsToml; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; use codex_features::Features; -use codex_features::FeaturesToml; -use codex_git_utils::resolve_root_git_project_for_trust; -use codex_login::AuthCredentialsStoreMode; +use codex_login::AuthManagerConfig; use codex_mcp::mcp::McpConfig; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; -use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; -use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; -use codex_model_provider_info::OPENAI_PROVIDER_ID; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::ModelsManagerConfig; use codex_protocol::config_types::AltScreenMode; @@ -77,22 +70,16 @@ use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; -use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; -use schemars::JsonSchema; use serde::Deserialize; -use serde::Deserializer; -use serde::Serialize; use std::collections::BTreeMap; use std::collections::HashMap; use std::io::ErrorKind; @@ -102,7 +89,6 @@ use std::path::PathBuf; use crate::config::permissions::compile_permission_profile; use crate::config::permissions::get_readable_roots_required_for_codex_runtime; use crate::config::permissions::network_proxy_config_from_profile_network; -use crate::config::profile::ConfigProfile; use codex_network_proxy::NetworkProxyConfig; use toml::Value as TomlValue; use toml_edit::DocumentMut; @@ -112,8 +98,8 @@ pub mod edit; mod managed_features; mod network_proxy_spec; mod permissions; -pub mod profile; -pub mod schema; +#[cfg(test)] +mod schema; pub mod service; pub use codex_config::Constrained; pub use codex_config::ConstraintError; @@ -123,16 +109,6 @@ pub use codex_sandboxing::system_bwrap_warning; pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; -pub use permissions::FilesystemPermissionToml; -pub use permissions::FilesystemPermissionsToml; -pub use permissions::NetworkDomainPermissionToml; -pub use permissions::NetworkDomainPermissionsToml; -pub use permissions::NetworkToml; -pub use permissions::NetworkUnixSocketPermissionToml; -pub use permissions::NetworkUnixSocketPermissionsToml; -pub use permissions::PermissionProfileToml; -pub use permissions::PermissionsToml; -pub(crate) use permissions::overlay_network_domain_permissions; pub(crate) use permissions::resolve_permission_profile; pub use service::ConfigService; pub use service::ConfigServiceError; @@ -149,11 +125,6 @@ pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option = None; pub const CONFIG_TOML_FILE: &str = "config.toml"; const PROJECT_LOCAL_CODEX_DIR: &str = ".codex"; -const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [ - OPENAI_PROVIDER_ID, - OLLAMA_OSS_PROVIDER_ID, - LMSTUDIO_OSS_PROVIDER_ID, -]; fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?; @@ -378,7 +349,7 @@ pub struct Config { /// Controls whether the TUI uses the terminal's alternate screen buffer. /// - /// This is the same `tui.alternate_screen` value from `config.toml` (see [`Tui`]). + /// This is the same `tui.alternate_screen` value from `config.toml`. /// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere. /// - `always`: Always use alternate screen (original behavior). /// - `never`: Never use alternate screen (inline mode, preserves scrollback). @@ -630,6 +601,20 @@ pub struct Config { pub otel: codex_config::types::OtelConfig, } +impl AuthManagerConfig for Config { + fn codex_home(&self) -> PathBuf { + self.codex_home.clone() + } + + fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode { + self.cli_auth_credentials_store_mode + } + + fn forced_chatgpt_workspace_id(&self) -> Option { + self.forced_chatgpt_workspace_id.clone() + } +} + #[derive(Debug, Clone, Default)] pub struct ConfigBuilder { codex_home: Option, @@ -1137,26 +1122,7 @@ pub fn set_project_trust_level( /// Save the default OSS provider preference to config.toml pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::Result<()> { - // Validate that the provider is one of the known OSS providers - match provider { - LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => { - // Valid provider, continue - } - LEGACY_OLLAMA_CHAT_PROVIDER_ID => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - OLLAMA_CHAT_PROVIDER_REMOVED_ERROR, - )); - } - _ => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!( - "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}" - ), - )); - } - } + codex_config::config_toml::validate_oss_provider(provider)?; use toml_edit::value; let edits = [ConfigEdit::SetPath { @@ -1170,452 +1136,15 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R .map_err(|err| std::io::Error::other(format!("failed to persist config.toml: {err}"))) } -/// Base config deserialized from ~/.codex/config.toml. -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct ConfigToml { - /// Optional override of model selection. - pub model: Option, - /// Review model override used by the `/review` feature. - pub review_model: Option, - - /// Provider to use from the model_providers map. - pub model_provider: Option, - - /// Size of the context window for the model, in tokens. - pub model_context_window: Option, - - /// Token usage threshold triggering auto-compaction of conversation history. - pub model_auto_compact_token_limit: Option, - - /// Default approval policy for executing commands. - pub approval_policy: Option, - - /// Configures who approval requests are routed to for review once they have - /// been escalated. This does not disable separate safety checks such as - /// ARC. - pub approvals_reviewer: Option, - - #[serde(default)] - pub shell_environment_policy: ShellEnvironmentPolicyToml, - - /// Whether the model may request a login shell for shell-based tools. - /// Default to `true` - /// - /// If `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. - pub allow_login_shell: Option, - - /// Sandbox mode to use. - pub sandbox_mode: Option, - - /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. - pub sandbox_workspace_write: Option, - - /// Default named permissions profile to apply from the `[permissions]` - /// table. - pub default_permissions: Option, - - /// Named permissions profiles. - #[serde(default)] - pub permissions: Option, - - /// Optional external command to spawn for end-user notifications. - #[serde(default)] - pub notify: Option>, - - /// System instructions. - pub instructions: Option, - - /// Developer instructions inserted as a `developer` role message. - #[serde(default)] - pub developer_instructions: Option, - - /// Whether to inject the `` developer block. - pub include_permissions_instructions: Option, - - /// Whether to inject the `` developer block. - pub include_apps_instructions: Option, - - /// Whether to inject the `` user block. - pub include_environment_context: Option, - - /// Optional path to a file containing model instructions that will override - /// the built-in instructions for the selected model. Users are STRONGLY - /// DISCOURAGED from using this field, as deviating from the instructions - /// sanctioned by Codex will likely degrade model performance. - pub model_instructions_file: Option, - - /// Compact prompt used for history compaction. - pub compact_prompt: Option, - - /// Optional commit attribution text for commit message co-author trailers. - /// - /// Set to an empty string to disable automatic commit attribution. - pub commit_attribution: Option, - - /// When set, restricts ChatGPT login to a specific workspace identifier. - #[serde(default)] - pub forced_chatgpt_workspace_id: Option, - - /// When set, restricts the login mechanism users may use. - #[serde(default)] - pub forced_login_method: Option, - - /// Preferred backend for storing CLI auth credentials. - /// file (default): Use a file in the Codex home directory. - /// keyring: Use an OS-specific keyring service. - /// auto: Use the keyring if available, otherwise use a file. - #[serde(default)] - pub cli_auth_credentials_store: Option, - - /// Definition for MCP servers that Codex can reach out to for tool calls. - #[serde(default)] - // Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`. - #[schemars(schema_with = "crate::config::schema::mcp_servers_schema")] - pub mcp_servers: HashMap, - - /// Preferred backend for storing MCP OAuth credentials. - /// keyring: Use an OS-specific keyring service. - /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 - /// file: Use a file in the Codex home directory. - /// auto (default): Use the OS-specific keyring service if available, otherwise use a file. - #[serde(default)] - pub mcp_oauth_credentials_store: Option, - - /// Optional fixed port for the local HTTP callback server used during MCP OAuth login. - /// When unset, Codex will bind to an ephemeral port chosen by the OS. - pub mcp_oauth_callback_port: Option, - - /// Optional redirect URI to use during MCP OAuth login. - /// When set, this URI is used in the OAuth authorization request instead - /// of the local listener address. The local callback listener still binds - /// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided). - pub mcp_oauth_callback_url: Option, - - /// User-defined provider entries that extend the built-in list. Built-in - /// IDs cannot be overridden. - #[serde(default, deserialize_with = "deserialize_model_providers")] - pub model_providers: HashMap, - - /// Maximum number of bytes to include from an AGENTS.md project doc file. - pub project_doc_max_bytes: Option, - - /// Ordered list of fallback filenames to look for when AGENTS.md is missing. - pub project_doc_fallback_filenames: Option>, - - /// Token budget applied when storing tool/function outputs in the context manager. - pub tool_output_token_limit: Option, - - /// Maximum poll window for background terminal output (`write_stdin`), in milliseconds. - /// Default: `300000` (5 minutes). - pub background_terminal_max_timeout: Option, - - /// Optional absolute path to the Node runtime used by `js_repl`. - pub js_repl_node_path: Option, - - /// Ordered list of directories to search for Node modules in `js_repl`. - pub js_repl_node_module_dirs: Option>, - - /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. - pub zsh_path: Option, - - /// Profile to use from the `profiles` map. - pub profile: Option, - - /// Named profiles to facilitate switching between different configurations. - #[serde(default)] - pub profiles: HashMap, - - /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. - #[serde(default)] - pub history: Option, - - /// Directory where Codex stores the SQLite state DB. - /// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`. - pub sqlite_home: Option, - - /// Directory where Codex writes log files, for example `codex-tui.log`. - /// Defaults to `$CODEX_HOME/log`. - pub log_dir: Option, - - /// 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, - - /// Collection of settings that are specific to the TUI. - pub tui: Option, - - /// When set to `true`, `AgentReasoning` events will be hidden from the - /// UI/output. Defaults to `false`. - pub hide_agent_reasoning: Option, - - /// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. - /// Defaults to `false`. - pub show_raw_agent_reasoning: Option, - - pub model_reasoning_effort: Option, - pub plan_mode_reasoning_effort: Option, - pub model_reasoning_summary: Option, - /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). - pub model_verbosity: Option, - - /// Override to force-enable reasoning summaries for the configured model. - pub model_supports_reasoning_summaries: Option, - - /// Optional path to a JSON model catalog (applied on startup only). - /// Per-thread `config` overrides are accepted but do not reapply this (no-ops). - pub model_catalog_json: Option, - - /// Optionally specify a personality for the model - pub personality: Option, - - /// Optional explicit service tier preference for new turns (`fast` or `flex`). - pub service_tier: Option, - - /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). - pub chatgpt_base_url: Option, - - /// Base URL override for the built-in `openai` model provider. - pub openai_base_url: Option, - - /// Machine-local realtime audio device preferences used by realtime voice. - #[serde(default)] - pub audio: Option, - - /// Experimental / do not use. Overrides only the realtime conversation - /// websocket transport base URL (the `Op::RealtimeConversation` - /// `/v1/realtime` - /// connection) without changing normal provider HTTP requests. - pub experimental_realtime_ws_base_url: Option, - /// Experimental / do not use. Selects the realtime websocket model/snapshot - /// used for the `Op::RealtimeConversation` connection. - pub experimental_realtime_ws_model: Option, - /// Experimental / do not use. Realtime websocket session selection. - /// `version` controls v1/v2 and `type` controls conversational/transcription. - #[serde(default)] - pub realtime: Option, - /// Experimental / do not use. Overrides only the realtime conversation - /// websocket transport instructions (the `Op::RealtimeConversation` - /// `/ws` session.update instructions) without changing normal prompts. - pub experimental_realtime_ws_backend_prompt: Option, - /// Experimental / do not use. Replaces the synthesized realtime startup - /// context appended to websocket session instructions. An empty string - /// disables startup context injection entirely. - pub experimental_realtime_ws_startup_context: Option, - /// Experimental / do not use. Replaces the built-in realtime start - /// instructions inserted into developer messages when realtime becomes - /// active. - pub experimental_realtime_start_instructions: Option, - pub projects: Option>, - - /// Controls the web search tool mode: disabled, cached, or live. - pub web_search: Option, - - /// Nested tools section for feature toggles - pub tools: Option, - - /// Additional discoverable tools that can be suggested for installation. - pub tool_suggest: Option, - - /// Agent-related settings (thread limits, etc.). - pub agents: Option, - - /// Memories subsystem settings. - pub memories: Option, - - /// User-level skill config entries keyed by SKILL.md path. - pub skills: Option, - - /// User-level plugin config entries keyed by plugin name. - #[serde(default)] - pub plugins: HashMap, - - /// Centralized feature flags (new). Prefer this over individual toggles. - #[serde(default)] - // Injects known feature keys into the schema and forbids unknown keys. - #[schemars(schema_with = "crate::config::schema::features_schema")] - pub features: Option, - - /// Suppress warnings about unstable (under development) features. - pub suppress_unstable_features_warning: Option, - - /// Settings for ghost snapshots (used for undo). - #[serde(default)] - pub ghost_snapshot: Option, - - /// Markers used to detect the project root when searching parent - /// directories for `.codex` folders. Defaults to [".git"] when unset. - #[serde(default)] - pub project_root_markers: Option>, - - /// When `true`, checks for Codex updates on startup and surfaces update prompts. - /// Set to `false` only if your Codex updates are centrally managed. - /// Defaults to `true`. - pub check_for_update_on_startup: Option, - - /// When true, disables burst-paste detection for typed input entirely. - /// All characters are inserted as they are received, and no buffering - /// or placeholder replacement will occur for fast keypress bursts. - pub disable_paste_burst: Option, - - /// When `false`, disables analytics across Codex product surfaces in this machine. - /// Defaults to `true`. - pub analytics: Option, - - /// When `false`, disables feedback collection across Codex product surfaces. - /// Defaults to `true`. - pub feedback: Option, - - /// Settings for app-specific controls. - #[serde(default)] - pub apps: Option, - - /// OTEL configuration. - pub otel: Option, - - /// Windows-specific configuration. - #[serde(default)] - pub windows: Option, - - /// Tracks whether the Windows onboarding screen has been acknowledged. - pub windows_wsl_setup_acknowledged: Option, - - /// Collection of in-product notices (different from notifications) - /// See [`codex_config::types::Notice`] for more details - pub notice: Option, - - /// Legacy, now use features - /// Deprecated: ignored. Use `model_instructions_file`. - #[schemars(skip)] - pub experimental_instructions_file: Option, - pub experimental_compact_prompt_file: Option, - pub experimental_use_unified_exec_tool: Option, - pub experimental_use_freeform_apply_patch: Option, - /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". - pub oss_provider: Option, -} - -impl From for UserSavedConfig { - fn from(config_toml: ConfigToml) -> Self { - let profiles = config_toml - .profiles - .into_iter() - .map(|(k, v)| (k, v.into())) - .collect(); - - Self { - approval_policy: config_toml.approval_policy, - sandbox_mode: config_toml.sandbox_mode, - sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), - forced_chatgpt_workspace_id: config_toml.forced_chatgpt_workspace_id, - forced_login_method: config_toml.forced_login_method, - model: config_toml.model, - model_reasoning_effort: config_toml.model_reasoning_effort, - model_reasoning_summary: config_toml.model_reasoning_summary, - model_verbosity: config_toml.model_verbosity, - tools: config_toml.tools.map(From::from), - profile: config_toml.profile, - profiles, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct ProjectConfig { - pub trust_level: Option, -} - -impl ProjectConfig { - pub fn is_trusted(&self) -> bool { - matches!(self.trust_level, Some(TrustLevel::Trusted)) - } - - pub fn is_untrusted(&self) -> bool { - matches!(self.trust_level, Some(TrustLevel::Untrusted)) - } -} - #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RealtimeAudioConfig { - pub microphone: Option, - pub speaker: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RealtimeWsMode { - #[default] - Conversational, - Transcription, -} - -pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct RealtimeConfig { - pub version: RealtimeWsVersion, - #[serde(rename = "type")] - pub session_type: RealtimeWsMode, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct RealtimeToml { - pub version: Option, - #[serde(rename = "type")] - pub session_type: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct RealtimeAudioToml { - pub microphone: Option, - pub speaker: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct ToolsToml { - #[serde( - default, - deserialize_with = "deserialize_optional_web_search_tool_config" - )] - pub web_search: Option, - - /// Enable the `view_image` tool that lets the agent attach local images. - #[serde(default)] - pub view_image: Option, -} - -#[derive(Deserialize)] -#[serde(untagged)] -enum WebSearchToolConfigInput { - Enabled(bool), - Config(WebSearchToolConfig), -} - -fn deserialize_optional_web_search_tool_config<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let value = Option::::deserialize(deserializer)?; - - Ok(match value { - None => None, - Some(WebSearchToolConfigInput::Enabled(enabled)) => { - let _ = enabled; - None - } - Some(WebSearchToolConfigInput::Config(config)) => Some(config), - }) +pub struct AgentRoleConfig { + /// Human-facing role documentation used in spawn tool guidance. + /// Required for loaded user-defined roles after deprecated/new metadata precedence resolves. + pub description: Option, + /// Path to a role-specific config layer. + pub config_file: Option, + /// Candidate nicknames for agents spawned with this role. + pub nickname_candidates: Option>, } fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { @@ -1640,218 +1169,6 @@ fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { ToolSuggestConfig { discoverables } } -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct AgentsToml { - /// Maximum number of agent threads that can be open concurrently. - /// When unset, no limit is enforced. - #[schemars(range(min = 1))] - pub max_threads: Option, - /// Maximum nesting depth allowed for spawned agent threads. - /// Root sessions start at depth 0. - #[schemars(range(min = 1))] - pub max_depth: Option, - /// Default maximum runtime in seconds for agent job workers. - #[schemars(range(min = 1))] - pub job_max_runtime_seconds: Option, - - /// User-defined role declarations keyed by role name. - /// - /// Example: - /// ```toml - /// [agents.researcher] - /// description = "Research-focused role." - /// config_file = "./agents/researcher.toml" - /// nickname_candidates = ["Herodotus", "Ibn Battuta"] - /// ``` - #[serde(default, flatten)] - pub roles: BTreeMap, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct AgentRoleConfig { - /// Human-facing role documentation used in spawn tool guidance. - /// Required for loaded user-defined roles after deprecated/new metadata precedence resolves. - pub description: Option, - /// Path to a role-specific config layer. - pub config_file: Option, - /// Candidate nicknames for agents spawned with this role. - pub nickname_candidates: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct AgentRoleToml { - /// Human-facing role documentation used in spawn tool guidance. - /// Required unless supplied by the referenced agent role file. - pub description: Option, - - /// Path to a role-specific config layer. - /// Relative paths are resolved relative to the `config.toml` that defines them. - pub config_file: Option, - - /// Candidate nicknames for agents spawned with this role. - pub nickname_candidates: Option>, -} - -impl From for Tools { - fn from(tools_toml: ToolsToml) -> Self { - Self { - web_search: tools_toml.web_search.is_some().then_some(true), - view_image: tools_toml.view_image, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct GhostSnapshotToml { - /// Exclude untracked files larger than this many bytes from ghost snapshots. - #[serde(alias = "ignore_untracked_files_over_bytes")] - pub ignore_large_untracked_files: Option, - /// Ignore untracked directories that contain this many files or more. - /// (Still emits a warning unless warnings are disabled.) - #[serde(alias = "large_untracked_dir_warning_threshold")] - pub ignore_large_untracked_dirs: Option, - /// Disable all ghost snapshot warning events. - pub disable_warnings: Option, -} - -impl ConfigToml { - /// Derive the effective sandbox policy from the configuration. - fn derive_sandbox_policy( - &self, - sandbox_mode_override: Option, - profile_sandbox_mode: Option, - windows_sandbox_level: WindowsSandboxLevel, - resolved_cwd: &Path, - sandbox_policy_constraint: Option<&Constrained>, - ) -> SandboxPolicy { - let sandbox_mode_was_explicit = sandbox_mode_override.is_some() - || profile_sandbox_mode.is_some() - || self.sandbox_mode.is_some(); - let resolved_sandbox_mode = sandbox_mode_override - .or(profile_sandbox_mode) - .or(self.sandbox_mode) - .or_else(|| { - // If no sandbox_mode is set but this directory has a trust decision, - // default to workspace-write except on unsandboxed Windows where we - // default to read-only. - self.get_active_project(resolved_cwd).and_then(|p| { - if p.is_trusted() || p.is_untrusted() { - if cfg!(target_os = "windows") - && windows_sandbox_level - == codex_protocol::config_types::WindowsSandboxLevel::Disabled - { - Some(SandboxMode::ReadOnly) - } else { - Some(SandboxMode::WorkspaceWrite) - } - } else { - None - } - }) - }) - .unwrap_or_default(); - let mut sandbox_policy = match resolved_sandbox_mode { - SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(), - SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { - Some(SandboxWorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - }) => SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots.clone(), - read_only_access: ReadOnlyAccess::FullAccess, - network_access: *network_access, - exclude_tmpdir_env_var: *exclude_tmpdir_env_var, - exclude_slash_tmp: *exclude_slash_tmp, - }, - None => SandboxPolicy::new_workspace_write_policy(), - }, - SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess, - }; - let downgrade_workspace_write_if_unsupported = |policy: &mut SandboxPolicy| { - if cfg!(target_os = "windows") - // If the experimental Windows sandbox is enabled, do not force a downgrade. - && windows_sandbox_level - == codex_protocol::config_types::WindowsSandboxLevel::Disabled - && matches!(&*policy, SandboxPolicy::WorkspaceWrite { .. }) - { - *policy = SandboxPolicy::new_read_only_policy(); - } - }; - if matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) { - downgrade_workspace_write_if_unsupported(&mut sandbox_policy); - } - if !sandbox_mode_was_explicit - && let Some(constraint) = sandbox_policy_constraint - && let Err(err) = constraint.can_set(&sandbox_policy) - { - tracing::warn!( - error = %err, - "default sandbox policy is disallowed by requirements; falling back to required default" - ); - sandbox_policy = constraint.get().clone(); - downgrade_workspace_write_if_unsupported(&mut sandbox_policy); - } - sandbox_policy - } - - /// Resolves the cwd to an existing project, or returns None if ConfigToml - /// does not contain a project corresponding to cwd or a git repo for cwd - pub fn get_active_project(&self, resolved_cwd: &Path) -> Option { - let projects = self.projects.clone().unwrap_or_default(); - - let resolved_cwd_key = project_trust_key(resolved_cwd); - let resolved_cwd_raw_key = resolved_cwd.to_string_lossy().to_string(); - if let Some(project_config) = projects - .get(&resolved_cwd_key) - .or_else(|| projects.get(&resolved_cwd_raw_key)) - { - return Some(project_config.clone()); - } - - // If cwd lives inside a git repo/worktree, check whether the root git project - // (the primary repository working directory) is trusted. This lets - // worktrees inherit trust from the main project. - if let Some(repo_root) = resolve_root_git_project_for_trust(resolved_cwd) { - let repo_root_key = project_trust_key(repo_root.as_path()); - let repo_root_raw_key = repo_root.to_string_lossy().to_string(); - if let Some(project_config_for_root) = projects - .get(&repo_root_key) - .or_else(|| projects.get(&repo_root_raw_key)) - { - return Some(project_config_for_root.clone()); - } - } - - None - } - - pub fn get_config_profile( - &self, - override_profile: Option, - ) -> Result { - let profile = override_profile.or_else(|| self.profile.clone()); - - match profile { - Some(key) => { - if let Some(profile) = self.profiles.get(key.as_str()) { - return Ok(profile.clone()); - } - - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("config profile `{key}` not found"), - )) - } - None => Ok(ConfigProfile::default()), - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, @@ -1932,49 +1249,6 @@ pub struct ConfigOverrides { pub additional_writable_roots: Vec, } -fn validate_reserved_model_provider_ids( - model_providers: &HashMap, -) -> Result<(), String> { - let mut conflicts = model_providers - .keys() - .filter(|key| RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str())) - .map(|key| format!("`{key}`")) - .collect::>(); - conflicts.sort_unstable(); - if conflicts.is_empty() { - Ok(()) - } else { - Err(format!( - "model_providers contains reserved built-in provider IDs: {}. \ -Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).", - conflicts.join(", ") - )) - } -} - -fn validate_model_providers( - model_providers: &HashMap, -) -> Result<(), String> { - validate_reserved_model_provider_ids(model_providers)?; - for (key, provider) in model_providers { - provider - .validate() - .map_err(|message| format!("model_providers.{key}: {message}"))?; - } - Ok(()) -} - -fn deserialize_model_providers<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let model_providers = HashMap::::deserialize(deserializer)?; - validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?; - Ok(model_providers) -} - /// Resolves the OSS provider from CLI override, profile config, or global config. /// Returns `None` if no provider is configured at any level. pub fn resolve_oss_provider( diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 93b59cf5f5..0a37f46cdf 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -20,6 +20,8 @@ use codex_protocol::protocol::SandboxPolicy; use std::collections::HashSet; use std::sync::Arc; +const GLOBAL_ALLOWLIST_PATTERN: &str = "*"; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct NetworkProxySpec { config: NetworkProxyConfig, @@ -195,6 +197,8 @@ impl NetworkProxySpec { let allowlist_expansion_enabled = Self::allowlist_expansion_enabled(sandbox_policy, hard_deny_allowlist_misses); let denylist_expansion_enabled = Self::denylist_expansion_enabled(sandbox_policy); + let danger_full_access_denylist_only = + Self::danger_full_access_denylist_only_enabled(requirements, sandbox_policy); if let Some(enabled) = requirements.enabled { config.network.enabled = enabled; @@ -225,37 +229,43 @@ impl NetworkProxySpec { constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets); } - let managed_allowed_domains = if hard_deny_allowlist_misses { - Some( + if danger_full_access_denylist_only { + config + .network + .set_allowed_domains(vec![GLOBAL_ALLOWLIST_PATTERN.to_string()]); + } else { + let managed_allowed_domains = if hard_deny_allowlist_misses { + Some( + requirements + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + ) + } else { requirements .domains .as_ref() .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) - .unwrap_or_default(), - ) - } else { - requirements - .domains - .as_ref() - .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) - }; - if let Some(managed_allowed_domains) = managed_allowed_domains { - // Managed requirements seed the baseline allowlist. User additions - // can extend that baseline unless managed-only mode pins the - // effective allowlist to the managed set. - let effective_allowed_domains = if allowlist_expansion_enabled { - Self::merge_domain_lists( - managed_allowed_domains.clone(), - config.network.allowed_domains().as_deref().unwrap_or(&[]), - ) - } else { - managed_allowed_domains.clone() }; - config - .network - .set_allowed_domains(effective_allowed_domains); - constraints.allowed_domains = Some(managed_allowed_domains); - constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled); + if let Some(managed_allowed_domains) = managed_allowed_domains { + // Managed requirements seed the baseline allowlist. User additions + // can extend that baseline unless managed-only mode pins the + // effective allowlist to the managed set. + let effective_allowed_domains = if allowlist_expansion_enabled { + Self::merge_domain_lists( + managed_allowed_domains.clone(), + config.network.allowed_domains().as_deref().unwrap_or(&[]), + ) + } else { + managed_allowed_domains.clone() + }; + config + .network + .set_allowed_domains(effective_allowed_domains); + constraints.allowed_domains = Some(managed_allowed_domains); + constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled); + } } let managed_denied_domains = requirements .domains @@ -274,7 +284,7 @@ impl NetworkProxySpec { constraints.denied_domains = Some(managed_denied_domains); constraints.denylist_expansion_enabled = Some(denylist_expansion_enabled); } - if requirements.unix_sockets.is_some() { + if requirements.unix_sockets.is_some() && !danger_full_access_denylist_only { let allow_unix_sockets = requirements .unix_sockets .as_ref() @@ -289,6 +299,14 @@ impl NetworkProxySpec { config.network.allow_local_binding = allow_local_binding; constraints.allow_local_binding = Some(allow_local_binding); } + if danger_full_access_denylist_only { + config.network.allow_upstream_proxy = true; + constraints.allow_upstream_proxy = Some(true); + config.network.dangerously_allow_all_unix_sockets = true; + constraints.dangerously_allow_all_unix_sockets = Some(true); + config.network.allow_local_binding = true; + constraints.allow_local_binding = Some(true); + } (config, constraints) } @@ -307,6 +325,16 @@ impl NetworkProxySpec { requirements.managed_allowed_domains_only.unwrap_or(false) } + fn danger_full_access_denylist_only_enabled( + requirements: &NetworkConstraints, + sandbox_policy: &SandboxPolicy, + ) -> bool { + matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) + && requirements + .danger_full_access_denylist_only + .unwrap_or(false) + } + fn denylist_expansion_enabled(sandbox_policy: &SandboxPolicy) -> bool { matches!( sandbox_policy, diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 77007885a4..742512d600 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -1,8 +1,11 @@ use super::*; use crate::config_loader::NetworkDomainPermissionToml; use crate::config_loader::NetworkDomainPermissionsToml; +use crate::config_loader::NetworkUnixSocketPermissionToml; +use crate::config_loader::NetworkUnixSocketPermissionsToml; use codex_network_proxy::NetworkDomainPermission; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; fn domain_permissions( entries: impl IntoIterator, @@ -178,6 +181,147 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false)); } +#[test] +fn danger_full_access_denylist_only_allows_all_domains_and_enforces_managed_denies() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_allowed_domains(vec!["evil.com".to_string()]); + config + .network + .set_denied_domains(vec!["more-blocked.example.com".to_string()]); + let requirements = NetworkConstraints { + allow_upstream_proxy: Some(false), + dangerously_allow_all_unix_sockets: Some(false), + domains: Some(domain_permissions([ + ("*.example.com", NetworkDomainPermissionToml::Allow), + ("blocked.example.com", NetworkDomainPermissionToml::Deny), + ])), + danger_full_access_denylist_only: Some(true), + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/managed.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }), + allow_local_binding: Some(false), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("denylist-only yolo mode should allow all domains except managed denies"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec!["*".to_string()]) + ); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert!(spec.config.network.allow_upstream_proxy); + assert!(spec.config.network.dangerously_allow_all_unix_sockets); + assert!(spec.config.network.allow_local_binding); + assert_eq!(spec.constraints.allow_upstream_proxy, Some(true)); + assert_eq!( + spec.constraints.dangerously_allow_all_unix_sockets, + Some(true) + ); + assert_eq!(spec.constraints.allow_unix_sockets, None); + assert_eq!(spec.constraints.allow_local_binding, Some(true)); + assert_eq!(spec.constraints.allowed_domains, None); + assert_eq!(spec.constraints.allowlist_expansion_enabled, None); + assert_eq!( + spec.constraints.denied_domains, + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false)); +} + +#[test] +fn danger_full_access_denylist_only_does_not_change_workspace_write_behavior() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); + let requirements = NetworkConstraints { + allow_upstream_proxy: Some(false), + dangerously_allow_all_unix_sockets: Some(false), + domains: Some(domain_permissions([ + ("*.example.com", NetworkDomainPermissionToml::Allow), + ( + "managed-blocked.example.com", + NetworkDomainPermissionToml::Deny, + ), + ])), + danger_full_access_denylist_only: Some(true), + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/managed.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }), + allow_local_binding: Some(false), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("denylist-only yolo flag should not affect workspace-write mode"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec![ + "*.example.com".to_string(), + "api.example.com".to_string() + ]) + ); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec![ + "managed-blocked.example.com".to_string(), + "blocked.example.com".to_string() + ]) + ); + assert!(!spec.config.network.allow_upstream_proxy); + assert!(!spec.config.network.dangerously_allow_all_unix_sockets); + assert_eq!( + spec.config.network.allow_unix_sockets(), + vec!["/tmp/managed.sock".to_string()] + ); + assert!(!spec.config.network.allow_local_binding); + assert_eq!(spec.constraints.allow_upstream_proxy, Some(false)); + assert_eq!( + spec.constraints.dangerously_allow_all_unix_sockets, + Some(false) + ); + assert_eq!( + spec.constraints.allow_unix_sockets, + Some(vec!["/tmp/managed.sock".to_string()]) + ); + assert_eq!(spec.constraints.allow_local_binding, Some(false)); + assert_eq!( + spec.constraints.allowed_domains, + Some(vec!["*.example.com".to_string()]) + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); + assert_eq!( + spec.constraints.denied_domains, + Some(vec!["managed-blocked.example.com".to_string()]) + ); + assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true)); +} + #[test] fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { let mut config = NetworkProxyConfig::default(); diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 73dad1c73c..f8284d4ae7 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -1,256 +1,22 @@ use std::borrow::Cow; -use std::collections::BTreeMap; use std::io; use std::path::Component; use std::path::Path; use std::path::PathBuf; -use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission; -use codex_network_proxy::NetworkMode; +use codex_config::permissions_toml::FilesystemPermissionToml; +use codex_config::permissions_toml::NetworkToml; +use codex_config::permissions_toml::PermissionProfileToml; +use codex_config::permissions_toml::PermissionsToml; use codex_network_proxy::NetworkProxyConfig; +#[cfg(test)] use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; -use codex_network_proxy::normalize_host; -use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -pub struct PermissionsToml { - #[serde(flatten)] - pub entries: BTreeMap, -} - -impl PermissionsToml { - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct PermissionProfileToml { - pub filesystem: Option, - pub network: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -pub struct FilesystemPermissionsToml { - #[serde(flatten)] - pub entries: BTreeMap, -} - -impl FilesystemPermissionsToml { - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] -#[serde(untagged)] -pub enum FilesystemPermissionToml { - Access(FileSystemAccessMode), - Scoped(BTreeMap), -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -pub struct NetworkDomainPermissionsToml { - #[serde(flatten)] - pub entries: BTreeMap, -} - -impl NetworkDomainPermissionsToml { - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - #[cfg(test)] - pub(crate) fn allowed_domains(&self) -> Option> { - let allowed_domains: Vec = self - .entries - .iter() - .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) - .map(|(pattern, _)| pattern.clone()) - .collect(); - (!allowed_domains.is_empty()).then_some(allowed_domains) - } - - #[cfg(test)] - pub(crate) fn denied_domains(&self) -> Option> { - let denied_domains: Vec = self - .entries - .iter() - .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) - .map(|(pattern, _)| pattern.clone()) - .collect(); - (!denied_domains.is_empty()).then_some(denied_domains) - } -} - -#[derive( - Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, -)] -#[serde(rename_all = "lowercase")] -pub enum NetworkDomainPermissionToml { - Allow, - Deny, -} - -impl std::fmt::Display for NetworkDomainPermissionToml { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let permission = match self { - Self::Allow => "allow", - Self::Deny => "deny", - }; - f.write_str(permission) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -pub struct NetworkUnixSocketPermissionsToml { - #[serde(flatten)] - pub entries: BTreeMap, -} - -impl NetworkUnixSocketPermissionsToml { - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - pub(crate) fn allow_unix_sockets(&self) -> Vec { - self.entries - .iter() - .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) - .map(|(path, _)| path.clone()) - .collect() - } -} - -#[derive( - Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, -)] -#[serde(rename_all = "lowercase")] -pub enum NetworkUnixSocketPermissionToml { - Allow, - None, -} - -impl std::fmt::Display for NetworkUnixSocketPermissionToml { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let permission = match self { - Self::Allow => "allow", - Self::None => "none", - }; - f.write_str(permission) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct NetworkToml { - pub enabled: Option, - pub proxy_url: Option, - pub enable_socks5: Option, - pub socks_url: Option, - pub enable_socks5_udp: Option, - pub allow_upstream_proxy: Option, - pub dangerously_allow_non_loopback_proxy: Option, - pub dangerously_allow_all_unix_sockets: Option, - #[schemars(with = "Option")] - pub mode: Option, - pub domains: Option, - pub unix_sockets: Option, - pub allow_local_binding: Option, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "lowercase")] -enum NetworkModeSchema { - Limited, - Full, -} - -impl NetworkToml { - pub(crate) fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { - if let Some(enabled) = self.enabled { - config.network.enabled = enabled; - } - if let Some(proxy_url) = self.proxy_url.as_ref() { - config.network.proxy_url = proxy_url.clone(); - } - if let Some(enable_socks5) = self.enable_socks5 { - config.network.enable_socks5 = enable_socks5; - } - if let Some(socks_url) = self.socks_url.as_ref() { - config.network.socks_url = socks_url.clone(); - } - if let Some(enable_socks5_udp) = self.enable_socks5_udp { - config.network.enable_socks5_udp = enable_socks5_udp; - } - if let Some(allow_upstream_proxy) = self.allow_upstream_proxy { - config.network.allow_upstream_proxy = allow_upstream_proxy; - } - if let Some(dangerously_allow_non_loopback_proxy) = - self.dangerously_allow_non_loopback_proxy - { - config.network.dangerously_allow_non_loopback_proxy = - dangerously_allow_non_loopback_proxy; - } - if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets { - config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; - } - if let Some(mode) = self.mode { - config.network.mode = mode; - } - if let Some(domains) = self.domains.as_ref() { - overlay_network_domain_permissions(config, domains); - } - if let Some(unix_sockets) = self.unix_sockets.as_ref() { - let mut proxy_unix_sockets = config.network.unix_sockets.take().unwrap_or_default(); - for (path, permission) in &unix_sockets.entries { - let permission = match permission { - NetworkUnixSocketPermissionToml::Allow => { - ProxyNetworkUnixSocketPermission::Allow - } - NetworkUnixSocketPermissionToml::None => ProxyNetworkUnixSocketPermission::None, - }; - proxy_unix_sockets.entries.insert(path.clone(), permission); - } - config.network.unix_sockets = - (!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets); - } - if let Some(allow_local_binding) = self.allow_local_binding { - config.network.allow_local_binding = allow_local_binding; - } - } - - pub(crate) fn to_network_proxy_config(&self) -> NetworkProxyConfig { - let mut config = NetworkProxyConfig::default(); - self.apply_to_network_proxy_config(&mut config); - config - } -} - -pub(crate) fn overlay_network_domain_permissions( - config: &mut NetworkProxyConfig, - domains: &NetworkDomainPermissionsToml, -) { - for (pattern, permission) in &domains.entries { - let permission = match permission { - NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow, - NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny, - }; - config - .network - .upsert_domain_permission(pattern.clone(), permission, normalize_host); - } -} pub(crate) fn network_proxy_config_from_profile_network( network: Option<&NetworkToml>, diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index e3ea67d7be..e9191dbfa7 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -1,7 +1,15 @@ use super::*; use crate::config::Config; use crate::config::ConfigOverrides; -use crate::config::ConfigToml; +use codex_config::config_toml::ConfigToml; +use codex_config::permissions_toml::FilesystemPermissionsToml; +use codex_config::permissions_toml::NetworkDomainPermissionToml; +use codex_config::permissions_toml::NetworkDomainPermissionsToml; +use codex_config::permissions_toml::NetworkToml; +use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; +use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; +use codex_config::permissions_toml::PermissionProfileToml; +use codex_config::permissions_toml::PermissionsToml; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::BTreeMap; diff --git a/codex-rs/core/src/config/schema.rs b/codex-rs/core/src/config/schema.rs index bde38f7eb5..9507aff585 100644 --- a/codex-rs/core/src/config/schema.rs +++ b/codex-rs/core/src/config/schema.rs @@ -1,103 +1,6 @@ -use crate::config::ConfigToml; -use codex_config::types::RawMcpServerConfig; -use codex_features::FEATURES; -use codex_features::legacy_feature_keys; -use schemars::r#gen::SchemaGenerator; -use schemars::r#gen::SchemaSettings; -use schemars::schema::InstanceType; -use schemars::schema::ObjectValidation; -use schemars::schema::RootSchema; -use schemars::schema::Schema; -use schemars::schema::SchemaObject; -use serde_json::Map; -use serde_json::Value; -use std::path::Path; - -/// Schema for the `[features]` map with known + legacy keys only. -pub(crate) fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { - let mut object = SchemaObject { - instance_type: Some(InstanceType::Object.into()), - ..Default::default() - }; - - let mut validation = ObjectValidation::default(); - for feature in FEATURES { - if feature.id == codex_features::Feature::Artifact { - continue; - } - validation - .properties - .insert(feature.key.to_string(), schema_gen.subschema_for::()); - } - for legacy_key in legacy_feature_keys() { - validation - .properties - .insert(legacy_key.to_string(), schema_gen.subschema_for::()); - } - validation.additional_properties = Some(Box::new(Schema::Bool(false))); - object.object = Some(Box::new(validation)); - - Schema::Object(object) -} - -/// Schema for the `[mcp_servers]` map using the raw input shape. -pub(crate) fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema { - let mut object = SchemaObject { - instance_type: Some(InstanceType::Object.into()), - ..Default::default() - }; - - let validation = ObjectValidation { - additional_properties: Some(Box::new(schema_gen.subschema_for::())), - ..Default::default() - }; - object.object = Some(Box::new(validation)); - - Schema::Object(object) -} - -/// Build the config schema for `config.toml`. -pub fn config_schema() -> RootSchema { - SchemaSettings::draft07() - .with(|settings| { - settings.option_add_null_type = false; - }) - .into_generator() - .into_root_schema_for::() -} - -/// Canonicalize a JSON value by sorting its keys. -fn canonicalize(value: &Value) -> Value { - match value { - Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), - Value::Object(map) => { - let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - let mut sorted = Map::with_capacity(map.len()); - for (key, child) in entries { - sorted.insert(key.clone(), canonicalize(child)); - } - Value::Object(sorted) - } - _ => value.clone(), - } -} - -/// Render the config schema as pretty-printed JSON. -pub fn config_schema_json() -> anyhow::Result> { - let schema = config_schema(); - let value = serde_json::to_value(schema)?; - let value = canonicalize(&value); - let json = serde_json::to_vec_pretty(&value)?; - Ok(json) -} - -/// Write the config schema fixture to disk. -pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> { - let json = config_schema_json()?; - std::fs::write(out_path, json)?; - Ok(()) -} +use codex_config::schema::canonicalize; +use codex_config::schema::config_schema_json; +use codex_config::schema::write_config_schema; #[cfg(test)] #[path = "schema_tests.rs"] diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index e878270b80..d2426b1385 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -1,4 +1,3 @@ -use super::ConfigToml; use super::deserialize_config_toml_with_base; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; @@ -29,6 +28,7 @@ use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::OverriddenMetadata; use codex_app_server_protocol::WriteStatus; use codex_config::CONFIG_TOML_FILE; +use codex_config::config_toml::ConfigToml; use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value as JsonValue; use std::borrow::Cow; diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 48555f0b92..8961c00aa9 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -5,11 +5,12 @@ mod macos; #[cfg(test)] mod tests; -use crate::config::ConfigToml; use crate::config_loader::layer_io::LoadedConfigLayers; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigRequirementsWithSources; +use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::ProjectConfig; use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; @@ -544,7 +545,7 @@ struct ProjectTrustContext { #[derive(Deserialize)] struct ProjectTrustConfigToml { - projects: Option>, + projects: Option>, } struct ProjectTrustDecision { diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index ea782922b5..7978f37ec8 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -2,9 +2,7 @@ use super::LoaderOverrides; use super::load_config_layers_state; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; -use crate::config::ConfigToml; use crate::config::ConstraintError; -use crate::config::ProjectConfig; use crate::config_loader::CloudRequirementsLoadError; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerEntry; @@ -16,6 +14,8 @@ use crate::config_loader::RequirementSource; use crate::config_loader::load_requirements_toml; use crate::config_loader::version_for_toml; use codex_config::CONFIG_TOML_FILE; +use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::ProjectConfig; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::AskForApproval; diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 10e10e99fc..334e160f12 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -143,9 +143,13 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( pub async fn list_cached_accessible_connectors_from_mcp_tools( config: &Config, ) -> Option> { - let auth_manager = auth_manager_from_config(config); + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false); let auth = auth_manager.auth().await; - if !config.features.apps_enabled_for_auth(auth.as_ref()) { + if !config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth)) + { return Some(Vec::new()); } let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); @@ -182,9 +186,13 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config: &Config, force_refetch: bool, ) -> anyhow::Result { - let auth_manager = auth_manager_from_config(config); + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false); let auth = auth_manager.auth().await; - if !config.features.apps_enabled_for_auth(auth.as_ref()) { + if !config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth)) + { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), codex_apps_ready: true, @@ -417,7 +425,8 @@ async fn list_directory_connectors_for_tool_suggest_with_auth( let token_data = if let Some(auth) = auth { auth.get_token_data().ok() } else { - let auth_manager = auth_manager_from_config(config); + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false); auth_manager .auth() .await @@ -492,14 +501,6 @@ async fn chatgpt_get_request_with_token( } } -fn auth_manager_from_config(config: &Config) -> std::sync::Arc { - AuthManager::shared( - config.codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - ) -} - pub fn connector_display_label(connector: &AppInfo) -> String { format_connector_label(&connector.name, &connector.id) } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 17813ee7d0..1cd732f030 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -3,7 +3,6 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::config::Config; use crate::config::ConfigOverrides; -use crate::config::ConfigToml; use crate::config::Constrained; use crate::config::ManagedFeatures; use crate::config::NetworkProxySpec; @@ -16,6 +15,7 @@ use crate::config_loader::NetworkDomainPermissionsToml; use crate::config_loader::RequirementSource; use crate::config_loader::Sourced; use crate::test_support; +use codex_config::config_toml::ConfigToml; use codex_network_proxy::NetworkProxyConfig; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 5bb9194857..b313a43e71 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -2,9 +2,9 @@ use super::*; use crate::codex::make_session_and_context; use crate::codex::make_session_and_context_with_rx; use crate::config::ConfigBuilder; -use crate::config::ConfigToml; use crate::state::ActiveTurn; use codex_config::CONFIG_TOML_FILE; +use codex_config::config_toml::ConfigToml; use codex_config::types::AppConfig; use codex_config::types::AppToolConfig; use codex_config::types::AppToolsConfig; diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index a6740b05fb..5218256e20 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -1,7 +1,4 @@ -use crate::config::NetworkToml; -use crate::config::PermissionsToml; use crate::config::find_codex_home; -use crate::config::overlay_network_domain_permissions; use crate::config::resolve_permission_profile; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; @@ -16,6 +13,9 @@ use anyhow::Result; use async_trait::async_trait; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; +use codex_config::permissions_toml::NetworkToml; +use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::overlay_network_domain_permissions; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigState; use codex_network_proxy::NetworkProxyConfig; diff --git a/codex-rs/core/src/personality_migration.rs b/codex-rs/core/src/personality_migration.rs index 8a3786e79e..52cabf55de 100644 --- a/codex-rs/core/src/personality_migration.rs +++ b/codex-rs/core/src/personality_migration.rs @@ -1,4 +1,3 @@ -use crate::config::ConfigToml; use crate::config::edit::ConfigEditsBuilder; use crate::rollout::ARCHIVED_SESSIONS_SUBDIR; use crate::rollout::SESSIONS_SUBDIR; @@ -6,6 +5,7 @@ use crate::rollout::list::ThreadListConfig; use crate::rollout::list::ThreadListLayout; use crate::rollout::list::ThreadSortKey; use crate::rollout::list::get_threads_in_root; +use codex_config::config_toml::ConfigToml; use codex_protocol::config_types::Personality; use codex_protocol::protocol::SessionSource; use codex_rollout::state_db; diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 2b21a23703..1da98b85cb 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -25,6 +25,7 @@ use super::startup_sync::start_startup_remote_plugin_sync_once; use super::store::PluginInstallResult as StorePluginInstallResult; use super::store::PluginStore; use super::store::PluginStoreError; +use super::store::plugin_version_for_source; use super::sync_openai_plugins_repo; use crate::SkillMetadata; use crate::config::CONFIG_TOML_FILE; @@ -99,6 +100,13 @@ struct CachedFeaturedPluginIds { featured_plugin_ids: Vec, } +#[derive(Default)] +struct NonCuratedCacheRefreshState { + requested_roots: Option>, + last_refreshed_roots: Option>, + in_flight: bool, +} + fn featured_plugin_ids_cache_key( config: &Config, auth: Option<&CodexAuth>, @@ -312,6 +320,7 @@ pub struct PluginsManager { codex_home: PathBuf, store: PluginStore, featured_plugin_ids_cache: RwLock>, + non_curated_cache_refresh_state: RwLock, cached_enabled_outcome: RwLock>, remote_sync_lock: Mutex<()>, restriction_product: Option, @@ -338,6 +347,7 @@ impl PluginsManager { codex_home: codex_home.clone(), store: PluginStore::new(codex_home), featured_plugin_ids_cache: RwLock::new(None), + non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()), cached_enabled_outcome: RwLock::new(None), remote_sync_lock: Mutex::new(()), restriction_product, @@ -719,6 +729,7 @@ impl PluginsManager { )); } + let mut missing_remote_plugins = Vec::::new(); let mut remote_installed_plugin_names = HashSet::::new(); for plugin in remote_plugins { if plugin.marketplace_name != marketplace_name { @@ -727,11 +738,7 @@ impl PluginsManager { }); } if !local_plugin_names.contains(&plugin.name) { - warn!( - plugin = plugin.name, - marketplace = %marketplace_name, - "ignoring remote plugin missing from local marketplace during sync" - ); + missing_remote_plugins.push(plugin.name); continue; } // For now, sync treats remote `enabled = false` as uninstall rather than a distinct @@ -753,6 +760,19 @@ impl PluginsManager { let mut result = RemotePluginSyncResult::default(); let remote_plugin_count = remote_installed_plugin_names.len(); let local_plugin_count = local_plugins.len(); + if !missing_remote_plugins.is_empty() { + let sample_missing_plugins = missing_remote_plugins + .iter() + .take(10) + .cloned() + .collect::>(); + warn!( + marketplace = %marketplace_name, + missing_remote_plugin_count = missing_remote_plugins.len(), + missing_remote_plugin_examples = ?sample_missing_plugins, + "ignoring remote plugins missing from local marketplace during sync" + ); + } for ( plugin_name, @@ -1034,6 +1054,56 @@ impl PluginsManager { } } + pub fn maybe_start_non_curated_plugin_cache_refresh_for_roots( + self: &Arc, + roots: &[AbsolutePathBuf], + ) { + let mut roots = roots.to_vec(); + roots.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path())); + roots.dedup(); + if roots.is_empty() { + return; + } + + let should_spawn = { + let mut state = match self.non_curated_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + // Collapse repeated plugin/list requests onto one worker and only queue another pass + // when the requested roots set actually changes. + if state.requested_roots.as_ref() == Some(&roots) + || (!state.in_flight && state.last_refreshed_roots.as_ref() == Some(&roots)) + { + return; + } + state.requested_roots = Some(roots); + if state.in_flight { + false + } else { + state.in_flight = true; + true + } + }; + if !should_spawn { + return; + } + + let manager = Arc::clone(self); + if let Err(err) = std::thread::Builder::new() + .name("plugins-non-curated-cache-refresh".to_string()) + .spawn(move || manager.run_non_curated_plugin_cache_refresh_loop()) + { + let mut state = match self.non_curated_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.in_flight = false; + state.requested_roots = None; + warn!("failed to start non-curated plugin cache refresh task: {err}"); + } + } + fn start_curated_repo_sync(self: &Arc) { if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) { return; @@ -1045,8 +1115,13 @@ impl PluginsManager { .spawn( move || match sync_openai_plugins_repo(codex_home.as_path()) { Ok(curated_plugin_version) => { - let configured_curated_plugin_ids = - configured_curated_plugin_ids_from_codex_home(codex_home.as_path()); + let configured_curated_plugin_ids = curated_plugin_ids_from_config_keys( + configured_plugins_from_codex_home( + codex_home.as_path(), + "failed to read user config while refreshing curated plugin cache", + "failed to parse user config while refreshing curated plugin cache", + ), + ); match refresh_curated_plugin_cache( codex_home.as_path(), &curated_plugin_version, @@ -1076,6 +1151,55 @@ impl PluginsManager { } } + fn run_non_curated_plugin_cache_refresh_loop(self: Arc) { + loop { + let roots = { + let state = match self.non_curated_cache_refresh_state.read() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.requested_roots.clone() + }; + + let Some(roots) = roots else { + let mut state = match self.non_curated_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.in_flight = false; + return; + }; + + let refreshed = + match refresh_non_curated_plugin_cache(self.codex_home.as_path(), &roots) { + Ok(cache_refreshed) => { + if cache_refreshed { + self.clear_cache(); + } + true + } + Err(err) => { + self.clear_cache(); + warn!("failed to refresh non-curated plugin cache: {err}"); + false + } + }; + + let mut state = match self.non_curated_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + if refreshed { + state.last_refreshed_roots = Some(roots.clone()); + } + if state.requested_roots.as_ref() == Some(&roots) { + state.requested_roots = None; + state.in_flight = false; + return; + } + } + } + fn configured_plugin_states(&self, config: &Config) -> (HashSet, HashSet) { let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack); let installed_plugins = configured_plugins @@ -1308,6 +1432,90 @@ fn refresh_curated_plugin_cache( Ok(cache_refreshed) } +fn refresh_non_curated_plugin_cache( + codex_home: &Path, + additional_roots: &[AbsolutePathBuf], +) -> Result { + let configured_non_curated_plugin_ids = + non_curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home( + codex_home, + "failed to read user config while refreshing non-curated plugin cache", + "failed to parse user config while refreshing non-curated plugin cache", + )); + if configured_non_curated_plugin_ids.is_empty() { + return Ok(false); + } + let configured_non_curated_plugin_keys = configured_non_curated_plugin_ids + .iter() + .map(PluginId::as_key) + .collect::>(); + + let store = PluginStore::new(codex_home.to_path_buf()); + let marketplace_outcome = list_marketplaces(additional_roots) + .map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?; + let mut plugin_sources = HashMap::::new(); + + for marketplace in marketplace_outcome.marketplaces { + if marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME { + continue; + } + + for plugin in marketplace.plugins { + let plugin_id = + PluginId::new(plugin.name.clone(), marketplace.name.clone()).map_err(|err| { + match err { + PluginIdError::Invalid(message) => { + format!("failed to prepare non-curated plugin cache refresh: {message}") + } + } + })?; + let plugin_key = plugin_id.as_key(); + if !configured_non_curated_plugin_keys.contains(&plugin_key) { + continue; + } + if plugin_sources.contains_key(&plugin_key) { + warn!( + plugin = plugin.name, + marketplace = marketplace.name, + "ignoring duplicate non-curated plugin entry during cache refresh" + ); + continue; + } + + let source_path = match plugin.source { + MarketplacePluginSource::Local { path } => path, + }; + let plugin_version = plugin_version_for_source(source_path.as_path()) + .map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?; + plugin_sources.insert(plugin_key, (source_path, plugin_version)); + } + } + + let mut cache_refreshed = false; + for plugin_id in configured_non_curated_plugin_ids { + let plugin_key = plugin_id.as_key(); + let Some((source_path, plugin_version)) = plugin_sources.get(&plugin_key).cloned() else { + warn!( + plugin = plugin_id.plugin_name, + marketplace = plugin_id.marketplace_name, + "configured non-curated plugin no longer exists in discovered marketplaces during cache refresh" + ); + continue; + }; + + if store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str()) { + continue; + } + + store + .install_with_version(source_path, plugin_id.clone(), plugin_version) + .map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?; + cache_refreshed = true; + } + + Ok(cache_refreshed) +} + fn configured_plugins_from_stack( config_layer_stack: &ConfigLayerStack, ) -> HashMap { @@ -1333,42 +1541,22 @@ fn configured_plugins_from_user_config_value( } } -fn configured_curated_plugin_ids( - configured_plugins: HashMap, -) -> Vec { - let mut configured_curated_plugin_ids = configured_plugins - .into_keys() - .filter_map(|plugin_key| match PluginId::parse(&plugin_key) { - Ok(plugin_id) if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME => { - Some(plugin_id) - } - Ok(_) => None, - Err(err) => { - warn!( - plugin_key, - error = %err, - "ignoring invalid configured plugin key during curated sync setup" - ); - None - } - }) - .collect::>(); - configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key); - configured_curated_plugin_ids -} - -fn configured_curated_plugin_ids_from_codex_home(codex_home: &Path) -> Vec { +fn configured_plugins_from_codex_home( + codex_home: &Path, + read_error_message: &str, + parse_error_message: &str, +) -> HashMap { let config_path = codex_home.join(CONFIG_TOML_FILE); let user_config = match fs::read_to_string(&config_path) { Ok(user_config) => user_config, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return HashMap::new(), Err(err) => { warn!( path = %config_path.display(), error = %err, - "failed to read user config while refreshing curated plugin cache" + "{read_error_message}" ); - return Vec::new(); + return HashMap::new(); } }; @@ -1378,13 +1566,61 @@ fn configured_curated_plugin_ids_from_codex_home(codex_home: &Path) -> Vec, + invalid_plugin_key_message: &str, +) -> Vec { + configured_plugins + .into_keys() + .filter_map(|plugin_key| match PluginId::parse(&plugin_key) { + Ok(plugin_id) => Some(plugin_id), + Err(err) => { + warn!( + plugin_key, + error = %err, + "{invalid_plugin_key_message}" + ); + None + } + }) + .collect() +} + +fn curated_plugin_ids_from_config_keys( + configured_plugins: HashMap, +) -> Vec { + let mut configured_curated_plugin_ids = configured_plugin_ids( + configured_plugins, + "ignoring invalid configured plugin key during curated sync setup", + ) + .into_iter() + .filter(|plugin_id| plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME) + .collect::>(); + configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key); + configured_curated_plugin_ids +} + +fn non_curated_plugin_ids_from_config_keys( + configured_plugins: HashMap, +) -> Vec { + let mut configured_non_curated_plugin_ids = configured_plugin_ids( + configured_plugins, + "ignoring invalid plugin key during non-curated cache refresh setup", + ) + .into_iter() + .filter(|plugin_id| plugin_id.marketplace_name != OPENAI_CURATED_MARKETPLACE_NAME) + .collect::>(); + configured_non_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key); + configured_non_curated_plugin_ids } fn load_plugin( diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index a9f455bbc4..2f1d3ae9c6 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -30,19 +30,36 @@ use wiremock::matchers::query_param; const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; -fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { +fn write_plugin_with_version( + root: &Path, + dir_name: &str, + manifest_name: &str, + manifest_version: Option<&str>, +) { let plugin_root = root.join(dir_name); fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); fs::create_dir_all(plugin_root.join("skills")).unwrap(); + let version = manifest_version + .map(|manifest_version| format!(r#","version":"{manifest_version}""#)) + .unwrap_or_default(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), - format!(r#"{{"name":"{manifest_name}"}}"#), + format!(r#"{{"name":"{manifest_name}"{version}}}"#), ) .unwrap(); fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap(); fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); } +fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { + write_plugin_with_version( + root, + dir_name, + manifest_name, + /*manifest_version*/ None, + ); +} + fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { let mut root = toml::map::Map::new(); @@ -955,6 +972,60 @@ async fn install_plugin_updates_config_with_relative_path_and_plugin_key() { assert!(config.contains("enabled = true")); } +#[tokio::test] +async fn install_plugin_uses_manifest_version_for_non_curated_plugins() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin_with_version( + &repo_root, + "sample-plugin", + "sample-plugin", + Some("1.2.3-beta+7"), + ); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ) + .unwrap(); + + let result = PluginsManager::new(tmp.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "sample-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }) + .await + .unwrap(); + + let installed_path = tmp + .path() + .join("plugins/cache/debug/sample-plugin/1.2.3-beta+7"); + assert_eq!( + result, + PluginInstallOutcome { + plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(), + plugin_version: "1.2.3-beta+7".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + } + ); +} + #[tokio::test] async fn uninstall_plugin_removes_cache_and_config_entry() { let tmp = tempfile::tempdir().unwrap(); @@ -2208,7 +2279,7 @@ fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_curren } #[test] -fn configured_curated_plugin_ids_from_codex_home_reads_latest_user_config() { +fn curated_plugin_ids_from_config_keys_reads_latest_codex_home_user_config() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), @@ -2224,10 +2295,14 @@ enabled = true ); assert_eq!( - configured_curated_plugin_ids_from_codex_home(tmp.path()) - .into_iter() - .map(|plugin_id| plugin_id.as_key()) - .collect::>(), + curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home( + tmp.path(), + "failed to read user config while refreshing curated plugin cache", + "failed to parse user config while refreshing curated plugin cache", + )) + .into_iter() + .map(|plugin_id| plugin_id.as_key()) + .collect::>(), vec!["slack@openai-curated".to_string()] ); @@ -2239,7 +2314,11 @@ plugins = true ); assert_eq!( - configured_curated_plugin_ids_from_codex_home(tmp.path()), + curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home( + tmp.path(), + "failed to read user config while refreshing curated plugin cache", + "failed to parse user config while refreshing curated plugin cache", + )), Vec::::new() ); } @@ -2266,6 +2345,212 @@ fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_curren ); } +#[test] +fn refresh_non_curated_plugin_cache_replaces_existing_local_version_with_manifest_version() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + assert!( + refresh_non_curated_plugin_cache( + tmp.path(), + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) + .expect("cache refresh should succeed") + ); + + assert!( + !tmp.path() + .join("plugins/cache/debug/sample-plugin/local") + .exists() + ); + assert!( + tmp.path() + .join("plugins/cache/debug/sample-plugin/1.2.3") + .is_dir() + ); +} + +#[test] +fn refresh_non_curated_plugin_cache_reinstalls_missing_configured_plugin_with_manifest_version() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + assert!( + refresh_non_curated_plugin_cache( + tmp.path(), + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) + .expect("cache refresh should reinstall missing configured plugin") + ); + + assert!( + tmp.path() + .join("plugins/cache/debug/sample-plugin/1.2.3") + .is_dir() + ); +} + +#[test] +fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_current() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ); + write_plugin_with_version( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/1.2.3", + "sample-plugin", + Some("1.2.3"), + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + assert!( + !refresh_non_curated_plugin_cache( + tmp.path(), + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) + .expect("cache refresh should be a no-op when configured plugins are current") + ); +} + +#[test] +fn refresh_non_curated_plugin_cache_ignores_invalid_unconfigured_plugin_versions() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); + write_plugin_with_version(&repo_root, "broken-plugin", "broken-plugin", Some(" ")); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + }, + { + "name": "broken-plugin", + "source": { + "source": "local", + "path": "./broken-plugin" + } + } + ] +}"#, + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + assert!( + refresh_non_curated_plugin_cache( + tmp.path(), + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) + .expect("cache refresh should ignore unrelated invalid plugin manifests") + ); + + assert!( + tmp.path() + .join("plugins/cache/debug/sample-plugin/1.2.3") + .is_dir() + ); +} + #[test] fn load_plugins_ignores_project_config_files() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core/src/plugins/manifest.rs b/codex-rs/core/src/plugins/manifest.rs index 8fbacb4565..f1253441bd 100644 --- a/codex-rs/core/src/plugins/manifest.rs +++ b/codex-rs/core/src/plugins/manifest.rs @@ -14,6 +14,8 @@ struct RawPluginManifest { #[serde(default)] name: String, #[serde(default)] + version: Option, + #[serde(default)] description: Option, // Keep manifest paths as raw strings so we can validate the required `./...` syntax before // resolving them under the plugin root. @@ -30,6 +32,7 @@ struct RawPluginManifest { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PluginManifest { pub(crate) name: String, + pub(crate) version: Option, pub(crate) description: Option, pub(crate) paths: PluginManifestPaths, pub(crate) interface: Option, @@ -121,6 +124,7 @@ pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option Ok(manifest) => { let RawPluginManifest { name: raw_name, + version, description, skills, mcp_servers, @@ -133,6 +137,10 @@ pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option .filter(|_| raw_name.trim().is_empty()) .unwrap_or(&raw_name) .to_string(); + let version = version.and_then(|version| { + let version = version.trim(); + (!version.is_empty()).then(|| version.to_string()) + }); let interface = interface.and_then(|interface| { let RawPluginManifestInterface { display_name, @@ -204,6 +212,7 @@ pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option }); Some(PluginManifest { name, + version, description, paths: PluginManifestPaths { skills: resolve_manifest_path(plugin_root, "skills", skills.as_deref()), @@ -381,13 +390,17 @@ mod tests { use std::path::Path; use tempfile::tempdir; - fn write_manifest(plugin_root: &Path, interface: &str) { + fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) { fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + let version = version + .map(|version| format!(" \"version\": \"{version}\",\n")) + .unwrap_or_default(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), format!( r#"{{ "name": "demo-plugin", +{version} "interface": {interface} }}"# ), @@ -405,6 +418,7 @@ mod tests { let plugin_root = tmp.path().join("demo-plugin"); write_manifest( &plugin_root, + /*version*/ None, r#"{ "displayName": "Demo Plugin", "defaultPrompt": " Summarize my inbox " @@ -427,6 +441,7 @@ mod tests { let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1); write_manifest( &plugin_root, + /*version*/ None, &format!( r#"{{ "displayName": "Demo Plugin", @@ -462,6 +477,7 @@ mod tests { let plugin_root = tmp.path().join("demo-plugin"); write_manifest( &plugin_root, + /*version*/ None, r#"{ "displayName": "Demo Plugin", "defaultPrompt": { "text": "Summarize my inbox" } @@ -473,4 +489,21 @@ mod tests { assert_eq!(interface.default_prompt, None); } + + #[test] + fn plugin_manifest_reads_trimmed_version() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_manifest( + &plugin_root, + Some(" 1.2.3-beta+7 "), + r#"{ + "displayName": "Demo Plugin" + }"#, + ); + + let manifest = load_manifest(&plugin_root); + + assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string())); + } } diff --git a/codex-rs/core/src/plugins/startup_sync.rs b/codex-rs/core/src/plugins/startup_sync.rs index 6dcf18f54f..14608d1313 100644 --- a/codex-rs/core/src/plugins/startup_sync.rs +++ b/codex-rs/core/src/plugins/startup_sync.rs @@ -24,16 +24,20 @@ use super::PluginsManager; const GITHUB_API_BASE_URL: &str = "https://api.github.com"; const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json"; const GITHUB_API_VERSION_HEADER: &str = "2022-11-28"; +const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str = + "https://chatgpt.com/backend-api/plugins/export/curated"; const OPENAI_PLUGINS_OWNER: &str = "openai"; const OPENAI_PLUGINS_REPO: &str = "plugins"; const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; +const CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION: &str = "export-backup"; const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); +const CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT: Duration = Duration::from_secs(30); // Keep this comfortably above a normal sync attempt so we do not race another Codex process. const CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE: Duration = Duration::from_secs(10 * 60); const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; -const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(5); +const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Debug, Deserialize)] struct GitHubRepositorySummary { @@ -50,22 +54,37 @@ struct GitHubGitRefObject { sha: String, } +#[derive(Debug, Deserialize)] +struct CuratedPluginsBackupArchiveResponse { + download_url: String, +} + pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf { codex_home.join(CURATED_PLUGINS_RELATIVE_DIR) } pub(crate) fn read_curated_plugins_sha(codex_home: &Path) -> Option { - read_sha_file(codex_home.join(CURATED_PLUGINS_SHA_FILE).as_path()) + read_sha_file(curated_plugins_sha_path(codex_home).as_path()) +} + +fn curated_plugins_sha_path(codex_home: &Path) -> PathBuf { + codex_home.join(CURATED_PLUGINS_SHA_FILE) } pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result { - sync_openai_plugins_repo_with_transport_overrides(codex_home, "git", GITHUB_API_BASE_URL) + sync_openai_plugins_repo_with_transport_overrides( + codex_home, + "git", + GITHUB_API_BASE_URL, + CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL, + ) } fn sync_openai_plugins_repo_with_transport_overrides( codex_home: &Path, git_binary: &str, api_base_url: &str, + backup_archive_api_url: &str, ) -> Result { match sync_openai_plugins_repo_via_git(codex_home, git_binary) { Ok(remote_sha) => { @@ -80,11 +99,46 @@ fn sync_openai_plugins_repo_with_transport_overrides( git_binary, "git sync failed for curated plugin sync; falling back to GitHub HTTP" ); - let result = sync_openai_plugins_repo_via_http(codex_home, api_base_url); - let status = if result.is_ok() { "success" } else { "failure" }; - emit_curated_plugins_startup_sync_metric("http", status); - emit_curated_plugins_startup_sync_final_metric("http", status); - result + match sync_openai_plugins_repo_via_http(codex_home, api_base_url) { + Ok(remote_sha) => { + emit_curated_plugins_startup_sync_metric("http", "success"); + emit_curated_plugins_startup_sync_final_metric("http", "success"); + Ok(remote_sha) + } + Err(http_err) => { + emit_curated_plugins_startup_sync_metric("http", "failure"); + if has_local_curated_plugins_snapshot(codex_home) { + emit_curated_plugins_startup_sync_final_metric("http", "failure"); + warn!( + error = %http_err, + "GitHub HTTP sync failed for curated plugin sync; skipping export archive fallback because a local curated plugins snapshot already exists" + ); + Err(format!( + "git sync failed for curated plugin sync: {err}; GitHub HTTP sync failed for curated plugin sync: {http_err}; export archive fallback skipped because a local curated plugins snapshot already exists" + )) + } else { + // The export archive is a lagging backup path. Only use it to bootstrap a + // missing local curated snapshot, never to refresh an existing one. + warn!( + error = %http_err, + backup_archive_api_url, + "GitHub HTTP sync failed for curated plugin sync; falling back to export archive" + ); + let result = sync_openai_plugins_repo_via_backup_archive( + codex_home, + backup_archive_api_url, + ); + let status = if result.is_ok() { "success" } else { "failure" }; + emit_curated_plugins_startup_sync_metric("export_archive", status); + emit_curated_plugins_startup_sync_final_metric("export_archive", status); + result.map_err(|export_err| { + format!( + "git sync failed for curated plugin sync: {err}; GitHub HTTP sync failed for curated plugin sync: {http_err}; export archive sync failed for curated plugin sync: {export_err}" + ) + }) + } + } + } } } } @@ -152,6 +206,29 @@ fn sync_openai_plugins_repo_via_http( Ok(remote_sha) } +fn sync_openai_plugins_repo_via_backup_archive( + codex_home: &Path, + backup_archive_api_url: &str, +) -> Result { + let repo_path = curated_plugins_repo_path(codex_home); + let sha_path = curated_plugins_sha_path(codex_home); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?; + let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let zipball_bytes = runtime.block_on(fetch_curated_repo_backup_archive_zip( + backup_archive_api_url, + ))?; + extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?; + ensure_marketplace_manifest_exists(staged_repo_dir.path())?; + let export_version = read_extracted_backup_archive_git_sha(staged_repo_dir.path())? + .unwrap_or_else(|| CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION.to_string()); + activate_curated_repo(&repo_path, staged_repo_dir)?; + write_curated_plugins_sha(&sha_path, &export_version)?; + Ok(export_version) +} + pub(super) fn start_startup_remote_plugin_sync_once( manager: Arc, codex_home: PathBuf, @@ -213,17 +290,17 @@ fn startup_remote_plugin_sync_marker_path(codex_home: &Path) -> PathBuf { codex_home.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE) } -fn startup_remote_plugin_sync_prerequisites_ready(codex_home: &Path) -> bool { - codex_home - .join(".tmp/plugins/.agents/plugins/marketplace.json") +fn has_local_curated_plugins_snapshot(codex_home: &Path) -> bool { + curated_plugins_repo_path(codex_home) + .join(".agents/plugins/marketplace.json") .is_file() - && codex_home.join(".tmp/plugins.sha").is_file() + && codex_home.join(CURATED_PLUGINS_SHA_FILE).is_file() } async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> bool { let deadline = tokio::time::Instant::now() + STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT; loop { - if startup_remote_plugin_sync_prerequisites_ready(codex_home) { + if has_local_curated_plugins_snapshot(codex_home) { return true; } if tokio::time::Instant::now() >= deadline { @@ -641,6 +718,126 @@ async fn fetch_curated_repo_zipball( fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await } +async fn fetch_curated_repo_backup_archive_zip( + backup_archive_api_url: &str, +) -> Result, String> { + let client = build_reqwest_client(); + let export_body = fetch_public_text( + &client, + backup_archive_api_url, + "get curated plugins export archive metadata", + ) + .await?; + let export_response: CuratedPluginsBackupArchiveResponse = serde_json::from_str(&export_body) + .map_err(|err| { + format!( + "failed to parse curated plugins backup archive response from {backup_archive_api_url}: {err}" + ) + })?; + if export_response.download_url.is_empty() { + return Err(format!( + "curated plugins backup archive response from {backup_archive_api_url} did not include a download URL" + )); + } + + fetch_public_bytes( + &client, + &export_response.download_url, + "download curated plugins export archive", + ) + .await +} + +fn read_extracted_backup_archive_git_sha(repo_path: &Path) -> Result, String> { + let git_dir = repo_path.join(".git"); + if !git_dir.is_dir() { + return Ok(None); + } + + let head_path = git_dir.join("HEAD"); + let head = std::fs::read_to_string(&head_path).map_err(|err| { + format!( + "failed to read curated plugins backup archive git HEAD {}: {err}", + head_path.display() + ) + })?; + let head = head.trim(); + if head.is_empty() { + return Err(format!( + "curated plugins backup archive git HEAD is empty at {}", + head_path.display() + )); + } + + if let Some(reference) = head.strip_prefix("ref: ") { + let reference = validate_backup_archive_git_ref(reference.trim())?; + return read_git_ref_sha(&git_dir, reference).map(Some); + } + + Ok(Some(head.to_string())) +} + +fn validate_backup_archive_git_ref(reference: &str) -> Result<&str, String> { + if !reference.starts_with("refs/") { + return Err(format!( + "curated plugins backup archive git ref must stay under refs/: {reference}" + )); + } + + let path = Path::new(reference); + if path.is_absolute() { + return Err(format!( + "curated plugins backup archive git ref must be relative: {reference}" + )); + } + + for component in path.components() { + match component { + std::path::Component::Normal(_) => {} + _ => { + return Err(format!( + "curated plugins backup archive git ref contains invalid path components: {reference}" + )); + } + } + } + + Ok(reference) +} + +fn read_git_ref_sha(git_dir: &Path, reference: &str) -> Result { + let ref_path = git_dir.join(reference); + if let Ok(sha) = std::fs::read_to_string(&ref_path) { + let sha = sha.trim(); + if sha.is_empty() { + return Err(format!( + "curated plugins backup archive git ref {reference} is empty at {}", + ref_path.display() + )); + } + return Ok(sha.to_string()); + } + + let packed_refs_path = git_dir.join("packed-refs"); + if let Ok(packed_refs) = std::fs::read_to_string(&packed_refs_path) + && let Some(sha) = packed_refs.lines().find_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('^') { + return None; + } + let (sha, candidate_ref) = trimmed.split_once(' ')?; + (candidate_ref == reference).then_some(sha.to_string()) + }) + { + return Ok(sha); + } + + Err(format!( + "failed to resolve curated plugins backup archive git ref {reference} from {}", + git_dir.display() + )) +} + async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result { let response = github_request(client, url) .send() @@ -675,6 +872,44 @@ async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result Ok(body.to_vec()) } +async fn fetch_public_text(client: &Client, url: &str, context: &str) -> Result { + let response = client + .get(url) + .timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!( + "{context} from {url} failed with status {status}: {body}" + )); + } + Ok(body) +} + +async fn fetch_public_bytes(client: &Client, url: &str, context: &str) -> Result, String> { + let response = client + .get(url) + .timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|err| format!("failed to read {context} response from {url}: {err}"))?; + if !status.is_success() { + let body_text = String::from_utf8_lossy(&body); + return Err(format!( + "{context} from {url} failed with status {status}: {body_text}" + )); + } + Ok(body.to_vec()) +} + fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder { client .get(url) diff --git a/codex-rs/core/src/plugins/startup_sync_tests.rs b/codex-rs/core/src/plugins/startup_sync_tests.rs index e1a1d8596f..cd0b91f4db 100644 --- a/codex-rs/core/src/plugins/startup_sync_tests.rs +++ b/codex-rs/core/src/plugins/startup_sync_tests.rs @@ -8,6 +8,7 @@ use codex_login::CodexAuth; use pretty_assertions::assert_eq; use std::io::Write; use std::path::Path; +use std::path::PathBuf; use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; @@ -33,6 +34,111 @@ fn has_plugins_clone_dirs(codex_home: &Path) -> bool { }) } +fn write_executable_script(path: &Path, contents: &str) { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + std::fs::write(path, contents).expect("write script"); + #[cfg(unix)] + { + let mut permissions = std::fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("chmod"); + } +} + +async fn mount_github_repo_and_ref(server: &MockServer, sha: &str) { + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins/git/ref/heads/main")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), + ) + .mount(server) + .await; +} + +async fn mount_github_zipball(server: &MockServer, sha: &str, bytes: Vec) { + Mock::given(method("GET")) + .and(path(format!("/repos/openai/plugins/zipball/{sha}"))) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/zip") + .set_body_bytes(bytes), + ) + .mount(server) + .await; +} + +async fn mount_export_archive(server: &MockServer, bytes: Vec) -> String { + let export_api_url = format!("{}/backend-api/plugins/export/curated", server.uri()); + Mock::given(method("GET")) + .and(path("/backend-api/plugins/export/curated")) + .respond_with(ResponseTemplate::new(200).set_body_string(format!( + r#"{{"download_url":"{}/files/curated-plugins.zip"}}"#, + server.uri() + ))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/files/curated-plugins.zip")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/zip") + .set_body_bytes(bytes), + ) + .mount(server) + .await; + export_api_url +} + +async fn run_sync_with_transport_overrides( + codex_home: PathBuf, + git_binary: impl Into, + api_base_url: impl Into, + backup_archive_api_url: impl Into, +) -> Result { + let git_binary = git_binary.into(); + let api_base_url = api_base_url.into(); + let backup_archive_api_url = backup_archive_api_url.into(); + tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_with_transport_overrides( + codex_home.as_path(), + &git_binary, + &api_base_url, + &backup_archive_api_url, + ) + }) + .await + .expect("sync task should join") +} + +async fn run_http_sync( + codex_home: PathBuf, + api_base_url: impl Into, +) -> Result { + let api_base_url = api_base_url.into(); + tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_via_http(codex_home.as_path(), &api_base_url) + }) + .await + .expect("sync task should join") +} + +fn assert_curated_gmail_repo(repo_path: &Path) { + assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); + assert!( + repo_path + .join("plugins/gmail/.codex-plugin/plugin.json") + .is_file() + ); +} + #[test] fn curated_plugins_repo_path_uses_codex_home_tmp_dir() { let tmp = tempdir().expect("tempdir"); @@ -100,8 +206,6 @@ fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() { #[cfg(unix)] #[test] fn sync_openai_plugins_repo_prefers_git_when_available() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-") @@ -110,9 +214,9 @@ fn sync_openai_plugins_repo_prefers_git_when_available() { let git_path = bin_dir.path().join("git"); let sha = "0123456789abcdef0123456789abcdef01234567"; - std::fs::write( + write_executable_script( &git_path, - format!( + &format!( r#"#!/bin/sh if [ "$1" = "ls-remote" ]; then printf '%s\tHEAD\n' "{sha}" @@ -135,89 +239,164 @@ echo "unexpected git invocation: $@" >&2 exit 1 "# ), - ) - .expect("write fake git"); - let mut permissions = std::fs::metadata(&git_path) - .expect("metadata") - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&git_path, permissions).expect("chmod"); + ); let synced_sha = sync_openai_plugins_repo_with_transport_overrides( tmp.path(), git_path.to_str().expect("utf8 path"), "http://127.0.0.1:9", + "http://127.0.0.1:9/backend-api/plugins/export/curated", ) .expect("git sync should succeed"); assert_eq!(synced_sha, sha); - assert!(curated_plugins_repo_path(tmp.path()).join(".git").is_dir()); - assert!( - curated_plugins_repo_path(tmp.path()) - .join(".agents/plugins/marketplace.json") - .is_file() - ); + let repo_path = curated_plugins_repo_path(tmp.path()); + assert!(repo_path.join(".git").is_dir()); + assert_curated_gmail_repo(&repo_path); assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); } +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { + let tmp = tempdir().expect("tempdir"); + let repo_root = tempfile::Builder::new() + .prefix("curated-repo-success-") + .tempdir() + .expect("tempdir"); + let work_repo = repo_root.path().join("work/plugins"); + let remote_repo = repo_root.path().join("remotes/openai/plugins.git"); + std::fs::create_dir_all(work_repo.join(".agents/plugins")).expect("create marketplace dir"); + std::fs::create_dir_all(work_repo.join("plugins/gmail/.codex-plugin")) + .expect("create plugin dir"); + std::fs::write( + work_repo.join(".agents/plugins/marketplace.json"), + r#"{"name":"openai-curated","plugins":[{"name":"gmail","source":{"source":"local","path":"./plugins/gmail"}}]}"#, + ) + .expect("write marketplace"); + std::fs::write( + work_repo.join("plugins/gmail/.codex-plugin/plugin.json"), + r#"{"name":"gmail"}"#, + ) + .expect("write plugin manifest"); + + let init_status = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("init") + .status() + .expect("run git init"); + assert!(init_status.success()); + + let add_status = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("add") + .arg(".") + .status() + .expect("run git add"); + assert!(add_status.success()); + + let commit_status = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("-c") + .arg("user.name=Codex Test") + .arg("-c") + .arg("user.email=codex@example.com") + .arg("commit") + .arg("-m") + .arg("init") + .status() + .expect("run git commit"); + assert!(commit_status.success()); + + std::fs::create_dir_all(remote_repo.parent().expect("remote parent")) + .expect("create remote parent"); + let clone_status = Command::new("git") + .arg("clone") + .arg("--bare") + .arg(&work_repo) + .arg(&remote_repo) + .status() + .expect("run git clone --bare"); + assert!(clone_status.success()); + + let sha_output = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("rev-parse") + .arg("HEAD") + .output() + .expect("run git rev-parse"); + assert!(sha_output.status.success()); + let sha = String::from_utf8_lossy(&sha_output.stdout) + .trim() + .to_string(); + + let git_config_path = repo_root.path().join("git-rewrite.conf"); + std::fs::write( + &git_config_path, + format!( + "[url \"file://{}/\"]\n insteadOf = https://github.com/\n", + repo_root.path().join("remotes").display() + ), + ) + .expect("write git config"); + + let bin_dir = tempfile::Builder::new() + .prefix("git-rewrite-wrapper-") + .tempdir() + .expect("tempdir"); + let git_wrapper = bin_dir.path().join("git"); + write_executable_script( + &git_wrapper, + &format!( + "#!/bin/sh\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + git_config_path.display() + ), + ); + + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("git sync should succeed"); + + assert_eq!(synced_sha, sha); + assert_curated_gmail_repo(&curated_plugins_repo_path(tmp.path())); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(sha.as_str()) + ); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + #[tokio::test] async fn sync_openai_plugins_repo_falls_back_to_http_when_git_is_unavailable() { let tmp = tempdir().expect("tempdir"); let server = MockServer::start().await; let sha = "0123456789abcdef0123456789abcdef01234567"; - Mock::given(method("GET")) - .and(path("/repos/openai/plugins")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/openai/plugins/git/ref/heads/main")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!("/repos/openai/plugins/zipball/{sha}"))) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/zip") - .set_body_bytes(curated_repo_zipball_bytes(sha)), - ) - .mount(&server) - .await; + mount_github_repo_and_ref(&server, sha).await; + mount_github_zipball(&server, sha, curated_repo_zipball_bytes(sha)).await; - let server_uri = server.uri(); - let tmp_path = tmp.path().to_path_buf(); - let synced_sha = tokio::task::spawn_blocking(move || { - sync_openai_plugins_repo_with_transport_overrides( - tmp_path.as_path(), - "missing-git-for-test", - &server_uri, - ) - }) + let synced_sha = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) .await - .expect("sync task should join") .expect("fallback sync should succeed"); let repo_path = curated_plugins_repo_path(tmp.path()); assert_eq!(synced_sha, sha); - assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); - assert!( - repo_path - .join("plugins/gmail/.codex-plugin/plugin.json") - .is_file() - ); + assert_curated_gmail_repo(&repo_path); assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); } #[cfg(unix)] #[tokio::test] async fn sync_openai_plugins_repo_falls_back_to_http_when_git_sync_fails() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-fail-") @@ -226,73 +405,36 @@ async fn sync_openai_plugins_repo_falls_back_to_http_when_git_sync_fails() { let git_path = bin_dir.path().join("git"); let sha = "0123456789abcdef0123456789abcdef01234567"; - std::fs::write( + write_executable_script( &git_path, r#"#!/bin/sh echo "simulated git failure" >&2 exit 1 "#, - ) - .expect("write fake git"); - let mut permissions = std::fs::metadata(&git_path) - .expect("metadata") - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&git_path, permissions).expect("chmod"); + ); let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/openai/plugins")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/openai/plugins/git/ref/heads/main")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!("/repos/openai/plugins/zipball/{sha}"))) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/zip") - .set_body_bytes(curated_repo_zipball_bytes(sha)), - ) - .mount(&server) - .await; + mount_github_repo_and_ref(&server, sha).await; + mount_github_zipball(&server, sha, curated_repo_zipball_bytes(sha)).await; - let server_uri = server.uri(); - let tmp_path = tmp.path().to_path_buf(); - let synced_sha = tokio::task::spawn_blocking(move || { - sync_openai_plugins_repo_with_transport_overrides( - tmp_path.as_path(), - git_path.to_str().expect("utf8 path"), - &server_uri, - ) - }) + let synced_sha = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + git_path.to_str().expect("utf8 path"), + server.uri(), + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) .await - .expect("sync task should join") .expect("fallback sync should succeed"); let repo_path = curated_plugins_repo_path(tmp.path()); assert_eq!(synced_sha, sha); - assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); - assert!( - repo_path - .join("plugins/gmail/.codex-plugin/plugin.json") - .is_file() - ); + assert_curated_gmail_repo(&repo_path); assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); } #[cfg(unix)] #[test] fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-partial-fail-") @@ -301,9 +443,9 @@ fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() { let git_path = bin_dir.path().join("git"); let sha = "0123456789abcdef0123456789abcdef01234567"; - std::fs::write( + write_executable_script( &git_path, - format!( + &format!( r#"#!/bin/sh if [ "$1" = "ls-remote" ]; then printf '%s\tHEAD\n' "{sha}" @@ -319,13 +461,7 @@ echo "unexpected git invocation: $@" >&2 exit 1 "# ), - ) - .expect("write fake git"); - let mut permissions = std::fs::metadata(&git_path) - .expect("metadata") - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&git_path, permissions).expect("chmod"); + ); let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path")) .expect_err("git sync should fail"); @@ -340,37 +476,12 @@ async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failu let server = MockServer::start().await; let sha = "0123456789abcdef0123456789abcdef01234567"; - Mock::given(method("GET")) - .and(path("/repos/openai/plugins")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/openai/plugins/git/ref/heads/main")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!("/repos/openai/plugins/zipball/{sha}"))) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/zip") - .set_body_bytes(b"not a zip archive".to_vec()), - ) - .mount(&server) - .await; + mount_github_repo_and_ref(&server, sha).await; + mount_github_zipball(&server, sha, b"not a zip archive".to_vec()).await; - let server_uri = server.uri(); - let tmp_path = tmp.path().to_path_buf(); - let err = tokio::task::spawn_blocking(move || { - sync_openai_plugins_repo_via_http(tmp_path.as_path(), &server_uri) - }) - .await - .expect("sync task should join") - .expect_err("http sync should fail"); + let err = run_http_sync(tmp.path().to_path_buf(), server.uri()) + .await + .expect_err("http sync should fail"); assert!(err.contains("failed to open curated plugins zip archive")); assert!(!has_plugins_clone_dirs(tmp.path())); @@ -391,37 +502,141 @@ async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() { std::fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha"); let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/openai/plugins")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/openai/plugins/git/ref/heads/main")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), - ) - .mount(&server) - .await; + mount_github_repo_and_ref(&server, sha).await; - let server_uri = server.uri(); - let tmp_path = tmp.path().to_path_buf(); - tokio::task::spawn_blocking(move || { - sync_openai_plugins_repo_with_transport_overrides( - tmp_path.as_path(), - "missing-git-for-test", - &server_uri, - ) - }) + run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) .await - .expect("sync task should join") .expect("sync should succeed"); assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); } +#[tokio::test] +async fn sync_openai_plugins_repo_falls_back_to_export_archive_when_no_snapshot_exists() { + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let export_sha = "1111111111111111111111111111111111111111"; + + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(500).set_body_string("github repo lookup failed")) + .mount(&server) + .await; + let export_api_url = + mount_export_archive(&server, curated_repo_backup_archive_zip_bytes(export_sha)).await; + + let synced_sha = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + export_api_url, + ) + .await + .expect("export fallback sync should succeed"); + + let repo_path = curated_plugins_repo_path(tmp.path()); + assert_eq!(synced_sha, export_sha); + assert_curated_gmail_repo(&repo_path); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(export_sha) + ); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_skips_export_archive_when_snapshot_exists() { + let tmp = tempdir().expect("tempdir"); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear"]); + write_curated_plugin_sha(tmp.path()); + + let plugin_manifest_path = curated_root.join("plugins/linear/.codex-plugin/plugin.json"); + let original_manifest = + std::fs::read_to_string(&plugin_manifest_path).expect("read existing plugin manifest"); + + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(500).set_body_string("github repo lookup failed")) + .mount(&server) + .await; + let export_api_url = mount_export_archive( + &server, + curated_repo_backup_archive_zip_bytes("2222222222222222222222222222222222222222"), + ) + .await; + + let err = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + export_api_url, + ) + .await + .expect_err("existing snapshot should suppress export fallback"); + + assert!(err.contains("export archive fallback skipped")); + assert_eq!( + std::fs::read_to_string(&plugin_manifest_path).expect("read plugin manifest after sync"), + original_manifest + ); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(TEST_CURATED_PLUGIN_SHA) + ); +} + +#[test] +fn read_extracted_backup_archive_git_sha_reads_head_ref_from_extracted_repo() { + let tmp = tempdir().expect("tempdir"); + let git_dir = tmp.path().join(".git/refs/heads"); + std::fs::create_dir_all(&git_dir).expect("create git ref dir"); + std::fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); + std::fs::write( + git_dir.join("main"), + "3333333333333333333333333333333333333333\n", + ) + .expect("write main ref"); + + assert_eq!( + read_extracted_backup_archive_git_sha(tmp.path()) + .expect("read extracted backup archive git sha"), + Some("3333333333333333333333333333333333333333".to_string()) + ); +} + +#[test] +fn read_extracted_backup_archive_git_sha_rejects_non_refs_head_target() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".git")).expect("create git dir"); + std::fs::write(tmp.path().join(".git/HEAD"), "ref: HEAD\n").expect("write HEAD"); + + let err = read_extracted_backup_archive_git_sha(tmp.path()) + .expect_err("non-refs target should be rejected"); + + assert!(err.contains("must stay under refs/")); +} + +#[test] +fn read_extracted_backup_archive_git_sha_rejects_path_traversal_ref() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".git")).expect("create git dir"); + std::fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/../../evil\n") + .expect("write HEAD"); + + let err = read_extracted_backup_archive_git_sha(tmp.path()) + .expect_err("path traversal ref should be rejected"); + + assert!(err.contains("invalid path components")); +} + #[tokio::test] async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() { let tmp = tempdir().expect("tempdir"); @@ -528,3 +743,49 @@ fn curated_repo_zipball_bytes(sha: &str) -> Vec { writer.finish().expect("finish zip writer").into_inner() } + +fn curated_repo_backup_archive_zip_bytes(sha: &str) -> Vec { + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(cursor); + let options = SimpleFileOptions::default(); + + writer + .start_file("plugins/.git/HEAD", options) + .expect("start HEAD entry"); + writer + .write_all(b"ref: refs/heads/main\n") + .expect("write HEAD"); + writer + .start_file("plugins/.git/refs/heads/main", options) + .expect("start main ref entry"); + writer + .write_all(format!("{sha}\n").as_bytes()) + .expect("write main ref"); + writer + .start_file("plugins/.agents/plugins/marketplace.json", options) + .expect("start marketplace entry"); + writer + .write_all( + br#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail" + } + } + ] +}"#, + ) + .expect("write marketplace"); + writer + .start_file("plugins/plugins/gmail/.codex-plugin/plugin.json", options) + .expect("start plugin manifest entry"); + writer + .write_all(br#"{"name":"gmail"}"#) + .expect("write plugin manifest"); + + writer.finish().expect("finish zip writer").into_inner() +} diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index faa7fc0812..4316f18002 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -1,8 +1,11 @@ use super::load_plugin_manifest; +use super::manifest::PluginManifest; use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::PLUGIN_MANIFEST_PATH; +use serde::Deserialize; +use serde_json::Value as JsonValue; use std::fs; use std::io; use std::path::Path; @@ -62,7 +65,7 @@ impl PluginStore { entry.file_type().ok().filter(std::fs::FileType::is_dir)?; entry.file_name().into_string().ok() }) - .filter(|version| validate_plugin_segment(version, "plugin version").is_ok()) + .filter(|version| validate_plugin_version_segment(version).is_ok()) .collect::>(); discovered_versions.sort_unstable(); if discovered_versions.is_empty() { @@ -91,7 +94,8 @@ impl PluginStore { source_path: AbsolutePathBuf, plugin_id: PluginId, ) -> Result { - self.install_with_version(source_path, plugin_id, DEFAULT_PLUGIN_VERSION.to_string()) + let plugin_version = plugin_version_for_source(source_path.as_path())?; + self.install_with_version(source_path, plugin_id, plugin_version) } pub fn install_with_version( @@ -114,8 +118,7 @@ impl PluginStore { plugin_id.plugin_name ))); } - validate_plugin_segment(&plugin_version, "plugin version") - .map_err(PluginStoreError::Invalid)?; + validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?; let installed_path = self.plugin_root(&plugin_id, &plugin_version); replace_plugin_root_atomically( source_path.as_path(), @@ -154,7 +157,33 @@ impl PluginStoreError { } } -fn plugin_name_for_source(source_path: &Path) -> Result { +pub(crate) fn plugin_version_for_source(source_path: &Path) -> Result { + let plugin_version = plugin_manifest_version_for_source(source_path)? + .unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string()); + validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?; + Ok(plugin_version) +} + +fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), String> { + if plugin_version.is_empty() { + return Err("invalid plugin version: must not be empty".to_string()); + } + if matches!(plugin_version, "." | "..") { + return Err("invalid plugin version: path traversal is not allowed".to_string()); + } + if !plugin_version + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '+')) + { + return Err( + "invalid plugin version: only ASCII letters, digits, `.`, `+`, `_`, and `-` are allowed" + .to_string(), + ); + } + Ok(()) +} + +fn plugin_manifest_for_source(source_path: &Path) -> Result { let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH); if !manifest_path.is_file() { return Err(PluginStoreError::Invalid(format!( @@ -163,12 +192,61 @@ fn plugin_name_for_source(source_path: &Path) -> Result, +} + +fn plugin_manifest_version_for_source( + source_path: &Path, +) -> Result, PluginStoreError> { + let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH); + if !manifest_path.is_file() { + return Err(PluginStoreError::Invalid(format!( + "missing plugin manifest: {}", + manifest_path.display() + ))); + } + + let contents = fs::read_to_string(&manifest_path) + .map_err(|err| PluginStoreError::io("failed to read plugin manifest", err))?; + let manifest: RawPluginManifestVersion = serde_json::from_str(&contents).map_err(|err| { + PluginStoreError::Invalid(format!( + "failed to parse plugin manifest {}: {err}", + manifest_path.display() + )) })?; + let Some(version) = manifest.version else { + return Ok(None); + }; + let Some(version) = version.as_str() else { + return Err(PluginStoreError::Invalid(format!( + "invalid plugin version in manifest {}: expected string", + manifest_path.display() + ))); + }; + let version = version.trim(); + if version.is_empty() { + return Err(PluginStoreError::Invalid(format!( + "invalid plugin version in manifest {}: must not be blank", + manifest_path.display() + ))); + } + Ok(Some(version.to_string())) +} + +fn plugin_name_for_source(source_path: &Path) -> Result { + let manifest = plugin_manifest_for_source(source_path)?; let plugin_name = manifest.name; validate_plugin_segment(&plugin_name, "plugin name") diff --git a/codex-rs/core/src/plugins/store_tests.rs b/codex-rs/core/src/plugins/store_tests.rs index e75e71c568..cb23443e80 100644 --- a/codex-rs/core/src/plugins/store_tests.rs +++ b/codex-rs/core/src/plugins/store_tests.rs @@ -3,19 +3,36 @@ use codex_plugin::PluginId; use pretty_assertions::assert_eq; use tempfile::tempdir; -fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { +fn write_plugin_with_version( + root: &Path, + dir_name: &str, + manifest_name: &str, + manifest_version: Option<&str>, +) { let plugin_root = root.join(dir_name); fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); fs::create_dir_all(plugin_root.join("skills")).unwrap(); + let version = manifest_version + .map(|manifest_version| format!(r#","version":"{manifest_version}""#)) + .unwrap_or_default(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), - format!(r#"{{"name":"{manifest_name}"}}"#), + format!(r#"{{"name":"{manifest_name}"{version}}}"#), ) .unwrap(); fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap(); fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); } +fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { + write_plugin_with_version( + root, + dir_name, + manifest_name, + /*manifest_version*/ None, + ); +} + #[test] fn install_copies_plugin_into_default_marketplace() { let tmp = tempdir().unwrap(); @@ -110,6 +127,62 @@ fn install_with_version_uses_requested_cache_version() { assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); } +#[test] +fn install_uses_manifest_version_when_present() { + let tmp = tempdir().unwrap(); + write_plugin_with_version( + tmp.path(), + "sample-plugin", + "sample-plugin", + Some("1.2.3-beta+7"), + ); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + let result = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + let installed_path = tmp + .path() + .join("plugins/cache/debug/sample-plugin/1.2.3-beta+7"); + assert_eq!( + result, + PluginInstallResult { + plugin_id, + plugin_version: "1.2.3-beta+7".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + } + ); + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); +} + +#[test] +fn install_rejects_blank_manifest_version() { + let tmp = tempdir().unwrap(); + write_plugin_with_version(tmp.path(), "sample-plugin", "sample-plugin", Some(" ")); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + let err = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id, + ) + .expect_err("blank manifest version should be rejected"); + let err = err.to_string().replace('\\', "/"); + + assert!( + err.starts_with("invalid plugin version in manifest "), + "unexpected error: {err}" + ); + assert!( + err.ends_with("sample-plugin/.codex-plugin/plugin.json: must not be blank"), + "unexpected error: {err}" + ); +} + #[test] fn active_plugin_version_reads_version_directory_name() { let tmp = tempdir().unwrap(); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 4a8513bb49..5fe39ee88f 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -26,12 +26,8 @@ pub async fn build_prompt_input( ) -> CodexResult> { config.ephemeral = true; - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - ); - auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); let thread_manager = ThreadManager::new( &config, diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index dae8f5c5ce..4eb4278048 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -1,6 +1,4 @@ use crate::codex::Session; -use crate::config::RealtimeWsMode; -use crate::config::RealtimeWsVersion; use crate::realtime_context::build_realtime_startup_context; use async_channel::Receiver; use async_channel::Sender; @@ -18,6 +16,8 @@ use codex_api::api_bridge::map_api_error; use codex_api::endpoint::realtime_websocket::RealtimeWebsocketEvents; use codex_api::endpoint::realtime_websocket::RealtimeWebsocketWriter; use codex_app_server_protocol::AuthMode; +use codex_config::config_toml::RealtimeWsMode; +use codex_config::config_toml::RealtimeWsVersion; use codex_login::CodexAuth; use codex_login::default_client::default_headers; use codex_login::read_openai_api_key_from_env; diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index e0d7741f41..a0600ddcec 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -57,5 +57,5 @@ pub(crate) struct SessionServices { /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, pub(crate) code_mode_service: CodeModeService, - pub(crate) environment: Arc, + pub(crate) environment: Option>, } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..2a26dbccad 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..3181e73698 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3db1735822 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index ec34fafb36..5fa005bac8 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -91,9 +91,13 @@ impl ToolHandler for ViewImageHandler { AbsolutePathBuf::try_from(turn.resolve_path(Some(args.path))).map_err(|error| { FunctionCallError::RespondToModel(format!("unable to resolve image path: {error}")) })?; + let Some(environment) = turn.environment.as_ref() else { + return Err(FunctionCallError::RespondToModel( + "view_image is unavailable in this session".to_string(), + )); + }; - let metadata = turn - .environment + let metadata = environment .get_filesystem() .get_metadata(&abs_path) .await @@ -110,8 +114,7 @@ impl ToolHandler for ViewImageHandler { abs_path.display() ))); } - let file_bytes = turn - .environment + let file_bytes = environment .get_filesystem() .read_file(&abs_path) .await diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 86610bbb2a..3858150da5 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -239,7 +239,12 @@ impl<'a> ToolRuntime for UnifiedExecRunt .await? { Some(prepared) => { - if ctx.turn.environment.exec_server_url().is_some() { + let Some(environment) = ctx.turn.environment.as_ref() else { + return Err(ToolError::Rejected( + "exec_command is unavailable in this session".to_string(), + )); + }; + if environment.is_remote() { return Err(ToolError::Rejected( "unified_exec zsh-fork is not supported when exec_server_url is configured".to_string(), )); @@ -251,7 +256,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &prepared.exec_request, req.tty, prepared.spawn_lifecycle, - ctx.turn.environment.as_ref(), + environment.as_ref(), ) .await .map_err(|err| match err { @@ -281,13 +286,18 @@ impl<'a> ToolRuntime for UnifiedExecRunt let exec_env = attempt .env_for(command, options, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; + let Some(environment) = ctx.turn.environment.as_ref() else { + return Err(ToolError::Rejected( + "exec_command is unavailable in this session".to_string(), + )); + }; self.manager .open_session_with_exec_env( req.process_id, &exec_env, req.tty, Box::new(NoopSpawnLifecycle), - ctx.turn.environment.as_ref(), + environment.as_ref(), ) .await .map_err(|err| match err { diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 8348614f95..a5db73bf45 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -98,7 +98,7 @@ async fn exec_command_with_tty( &request, tty, Box::new(NoopSpawnLifecycle), - turn.environment.as_ref(), + turn.environment.as_ref().expect("turn environment"), ) .await?, ); @@ -593,7 +593,7 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<() let remote_test_env = remote_test_env().await?; let (_, mut turn) = make_session_and_context().await; - turn.environment = Arc::new(remote_test_env.environment().clone()); + turn.environment = Some(Arc::new(remote_test_env.environment().clone())); let request = test_exec_request( &turn, @@ -611,7 +611,7 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<() Box::new(TestSpawnLifecycle { inherited_fds: vec![42], }), - turn.environment.as_ref(), + turn.environment.as_ref().expect("turn environment"), ) .await .expect_err("expected inherited fd rejection"); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a3465f7d63..affb72b69f 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -593,7 +593,7 @@ impl UnifiedExecProcessManager { .ok_or(UnifiedExecError::MissingCommandLine)?; let inherited_fds = spawn_lifecycle.inherited_fds(); - if environment.exec_server_url().is_some() { + if environment.is_remote() { if !inherited_fds.is_empty() { return Err(UnifiedExecError::create_process( "remote exec-server does not support inherited file descriptors".to_string(), diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 0def23d00c..789b1ce34a 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -1,7 +1,7 @@ use crate::config::Config; -use crate::config::ConfigToml; use crate::config::edit::ConfigEditsBuilder; -use crate::config::profile::ConfigProfile; +use codex_config::config_toml::ConfigToml; +use codex_config::profile_toml::ConfigProfile; use codex_config::types::WindowsSandboxModeToml; use codex_features::Feature; use codex_features::Features; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8e719460e5 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,10 +1,10 @@ +use codex_config::types::AuthCredentialsStoreMode; use codex_core::ModelClient; use codex_core::NewThread; use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ThreadManager; use codex_features::Feature; -use codex_login::AuthCredentialsStoreMode; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::default_client::originator; diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..9ddc169a60 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -1626,6 +1626,34 @@ text({ json: true }); Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_can_resume_after_set_timeout() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn( + &server, + "use exec to wait for a timeout", + r#" +await new Promise((resolve) => setTimeout(resolve, 10)); +text("timer done"); +"#, + /*include_apply_patch*/ false, + ) + .await?; + + let req = second_mock.single_request(); + let (output, success) = custom_tool_output_body_and_success(&req, "call-1"); + assert_ne!( + success, + Some(false), + "exec setTimeout call failed unexpectedly: {output}" + ); + assert_eq!(output, "timer done"); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_notify_injects_additional_exec_tool_output_into_active_context() -> Result<()> { skip_if_no_network!(Ok(())); @@ -2099,6 +2127,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "BigInt64Array", "BigUint64Array", "Boolean", + "clearTimeout", "DataView", "Date", "DisposableStack", @@ -2161,6 +2190,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "notify", "parseFloat", "parseInt", + "setTimeout", "store", "text", "tools", @@ -2578,3 +2608,51 @@ text(JSON.stringify(load("nb"))); Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_can_compare_elapsed_time_around_set_timeout() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn( + &server, + "measure elapsed time around setTimeout", + r#" +const start_ms = Date.now(); +await new Promise((resolve) => setTimeout(resolve, 100)); +const end_ms = Date.now(); +text(JSON.stringify({ + start_ms, + end_ms, + elapsed_ms: end_ms - start_ms, + waited_long_enough: end_ms - start_ms >= 100, +})); +"#, + /*include_apply_patch*/ false, + ) + .await?; + + let second_request = second_mock.single_request(); + let (second_output, second_success) = + custom_tool_output_body_and_success(&second_request, "call-1"); + assert_ne!( + second_success, + Some(false), + "exec compare time call failed unexpectedly: {second_output}" + ); + let compared: Value = serde_json::from_str( + &custom_tool_output_last_non_empty_text(&second_request, "call-1") + .expect("exec compare time call should emit JSON"), + )?; + let elapsed_ms = compared + .get("elapsed_ms") + .and_then(Value::as_i64) + .expect("elapsed_ms should be an integer"); + assert!( + elapsed_ms >= 100, + "expected elapsed_ms >= 100, got {elapsed_ms}" + ); + assert_eq!(compared.get("waited_long_enough"), Some(&Value::Bool(true))); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..6ab001383f 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::time::Duration; use anyhow::Result; -use codex_core::config::ProjectConfig; +use codex_config::config_toml::ProjectConfig; use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 0a8dd61d9c..3f19d55eff 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -1,6 +1,6 @@ +use codex_config::config_toml::ConfigToml; use codex_core::ARCHIVED_SESSIONS_SUBDIR; use codex_core::SESSIONS_SUBDIR; -use codex_core::config::ConfigToml; use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME; use codex_core::personality_migration::PersonalityMigrationStatus; use codex_core::personality_migration::maybe_migrate_personality; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f9ad64c771 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 0518612c38..c76ac431ec 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -14,44 +14,83 @@ use crate::remote_process::RemoteProcess; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; -pub trait ExecutorEnvironment: Send + Sync { - fn get_exec_backend(&self) -> Arc; -} - -#[derive(Debug, Default)] +/// Lazily creates and caches the active environment for a session. +/// +/// The manager keeps the session's environment selection stable so subagents +/// and follow-up turns preserve an explicit disabled state. +#[derive(Debug)] pub struct EnvironmentManager { exec_server_url: Option, - current_environment: OnceCell>, + disabled: bool, + current_environment: OnceCell>>, +} + +impl Default for EnvironmentManager { + fn default() -> Self { + Self::new(/*exec_server_url*/ None) + } } impl EnvironmentManager { + /// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value. pub fn new(exec_server_url: Option) -> Self { + let (exec_server_url, disabled) = normalize_exec_server_url(exec_server_url); Self { - exec_server_url: normalize_exec_server_url(exec_server_url), + exec_server_url, + disabled, current_environment: OnceCell::new(), } } + /// Builds a manager from process environment variables. pub fn from_env() -> Self { Self::new(std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok()) } + /// Builds a manager from the currently selected environment, or from the + /// disabled mode when no environment is available. + pub fn from_environment(environment: Option<&Environment>) -> Self { + match environment { + Some(environment) => Self { + exec_server_url: environment.exec_server_url().map(str::to_owned), + disabled: false, + current_environment: OnceCell::new(), + }, + None => Self { + exec_server_url: None, + disabled: true, + current_environment: OnceCell::new(), + }, + } + } + + /// Returns the remote exec-server URL when one is configured. pub fn exec_server_url(&self) -> Option<&str> { self.exec_server_url.as_deref() } - pub async fn current(&self) -> Result, ExecServerError> { + /// Returns the cached environment, creating it on first access. + pub async fn current(&self) -> Result>, ExecServerError> { self.current_environment .get_or_try_init(|| async { - Ok(Arc::new( - Environment::create(self.exec_server_url.clone()).await?, - )) + if self.disabled { + Ok(None) + } else { + Ok(Some(Arc::new( + Environment::create(self.exec_server_url.clone()).await?, + ))) + } }) .await - .map(Arc::clone) + .map(Option::as_ref) + .map(std::option::Option::<&Arc>::cloned) } } +/// Concrete execution/filesystem environment selected for a session. +/// +/// This bundles the selected backend together with the corresponding remote +/// client, if any. #[derive(Clone)] pub struct Environment { exec_server_url: Option, @@ -86,12 +125,19 @@ impl std::fmt::Debug for Environment { } impl Environment { + /// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value. pub async fn create(exec_server_url: Option) -> Result { - let exec_server_url = normalize_exec_server_url(exec_server_url); - let remote_exec_server_client = if let Some(url) = &exec_server_url { + let (exec_server_url, disabled) = normalize_exec_server_url(exec_server_url); + if disabled { + return Err(ExecServerError::Protocol( + "disabled mode does not create an Environment".to_string(), + )); + } + + let remote_exec_server_client = if let Some(exec_server_url) = &exec_server_url { Some( ExecServerClient::connect_websocket(RemoteExecServerConnectArgs { - websocket_url: url.clone(), + websocket_url: exec_server_url.clone(), client_name: "codex-environment".to_string(), connect_timeout: std::time::Duration::from_secs(5), initialize_timeout: std::time::Duration::from_secs(5), @@ -102,10 +148,14 @@ impl Environment { None }; - let exec_backend: Arc = - if let Some(client) = remote_exec_server_client.clone() { - Arc::new(RemoteProcess::new(client)) - } else { + let exec_backend: Arc = match remote_exec_server_client.clone() { + Some(client) => Arc::new(RemoteProcess::new(client)), + None if exec_server_url.is_some() => { + return Err(ExecServerError::Protocol( + "remote mode should have an exec-server client".to_string(), + )); + } + None => { let local_process = LocalProcess::default(); local_process .initialize() @@ -114,7 +164,8 @@ impl Environment { .initialized() .map_err(ExecServerError::Protocol)?; Arc::new(local_process) - }; + } + }; Ok(Self { exec_server_url, @@ -123,6 +174,11 @@ impl Environment { }) } + pub fn is_remote(&self) -> bool { + self.exec_server_url.is_some() + } + + /// Returns the remote exec-server URL when this environment is remote. pub fn exec_server_url(&self) -> Option<&str> { self.exec_server_url.as_deref() } @@ -132,27 +188,20 @@ impl Environment { } pub fn get_filesystem(&self) -> Arc { - if let Some(client) = self.remote_exec_server_client.clone() { - Arc::new(RemoteFileSystem::new(client)) - } else { - Arc::new(LocalFileSystem) + match self.remote_exec_server_client.clone() { + Some(client) => Arc::new(RemoteFileSystem::new(client)), + None => Arc::new(LocalFileSystem), } } } -fn normalize_exec_server_url(exec_server_url: Option) -> Option { - exec_server_url.and_then(|url| { - let url = url.trim(); - (!url.is_empty()).then(|| url.to_string()) - }) -} - -impl ExecutorEnvironment for Environment { - fn get_exec_backend(&self) -> Arc { - Arc::clone(&self.exec_backend) +fn normalize_exec_server_url(exec_server_url: Option) -> (Option, bool) { + match exec_server_url.as_deref().map(str::trim) { + None | Some("") => (None, false), + Some(url) if url.eq_ignore_ascii_case("none") => (None, true), + Some(url) => (Some(url.to_string()), false), } } - #[cfg(test)] mod tests { use std::sync::Arc; @@ -163,7 +212,7 @@ mod tests { use pretty_assertions::assert_eq; #[tokio::test] - async fn create_without_remote_exec_server_url_does_not_connect() { + async fn create_local_environment_does_not_connect() { let environment = Environment::create(/*exec_server_url*/ None) .await .expect("create environment"); @@ -176,6 +225,15 @@ mod tests { fn environment_manager_normalizes_empty_url() { let manager = EnvironmentManager::new(Some(String::new())); + assert!(!manager.disabled); + assert_eq!(manager.exec_server_url(), None); + } + + #[test] + fn environment_manager_treats_none_value_as_disabled() { + let manager = EnvironmentManager::new(Some("none".to_string())); + + assert!(manager.disabled); assert_eq!(manager.exec_server_url(), None); } @@ -186,9 +244,25 @@ mod tests { let first = manager.current().await.expect("get current environment"); let second = manager.current().await.expect("get current environment"); + let first = first.expect("local environment"); + let second = second.expect("local environment"); + assert!(Arc::ptr_eq(&first, &second)); } + #[tokio::test] + async fn disabled_environment_manager_has_no_current_environment() { + let manager = EnvironmentManager::new(Some("none".to_string())); + + assert!( + manager + .current() + .await + .expect("get current environment") + .is_none() + ); + } + #[tokio::test] async fn default_environment_has_ready_local_executor() { let environment = Environment::default(); diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 77834c6143..ab27ab2397 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -34,7 +34,6 @@ pub use codex_app_server_protocol::FsWriteFileResponse; pub use environment::CODEX_EXEC_SERVER_URL_ENV_VAR; pub use environment::Environment; pub use environment::EnvironmentManager; -pub use environment::ExecutorEnvironment; pub use file_system::CopyOptions; pub use file_system::CreateDirectoryOptions; pub use file_system::ExecutorFileSystem; diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..232be7f02c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..2a26ec3c7e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5b77582db5..346e8e9e5e 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -514,6 +514,66 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let default_sandbox_policy = config.permissions.sandbox_policy.get(); let default_effort = config.model_reasoning_effort; + let (initial_operation, prompt_summary) = match (command.as_ref(), prompt, images) { + (Some(ExecCommand::Review(review_cli)), _, _) => { + let review_request = build_review_request(review_cli)?; + let summary = codex_core::review_prompts::user_facing_hint(&review_request.target); + (InitialOperation::Review { review_request }, summary) + } + (Some(ExecCommand::Resume(args)), root_prompt, imgs) => { + let prompt_arg = args + .prompt + .clone() + .or_else(|| { + if args.last { + args.session_id.clone() + } else { + None + } + }) + .or(root_prompt); + let prompt_text = resolve_prompt(prompt_arg); + let mut items: Vec = imgs + .into_iter() + .chain(args.images.iter().cloned()) + .map(|path| UserInput::LocalImage { path }) + .collect(); + items.push(UserInput::Text { + text: prompt_text.clone(), + // CLI input doesn't track UI element ranges, so none are available here. + text_elements: Vec::new(), + }); + let output_schema = load_output_schema(output_schema_path.clone()); + ( + InitialOperation::UserTurn { + items, + output_schema, + }, + prompt_text, + ) + } + (None, root_prompt, imgs) => { + let prompt_text = resolve_root_prompt(root_prompt); + let mut items: Vec = imgs + .into_iter() + .map(|path| UserInput::LocalImage { path }) + .collect(); + items.push(UserInput::Text { + text: prompt_text.clone(), + // CLI input doesn't track UI element ranges, so none are available here. + text_elements: Vec::new(), + }); + let output_schema = load_output_schema(output_schema_path); + ( + InitialOperation::UserTurn { + items, + output_schema, + }, + prompt_text, + ) + } + }; + // When --yolo (dangerously_bypass_approvals_and_sandbox) is set, also skip the git repo check // since the user is explicitly running in an externally sandboxed environment. if !skip_git_repo_check @@ -588,66 +648,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { exec_span.record("thread.id", primary_thread_id_for_span.as_str()); - let (initial_operation, prompt_summary) = match (command.as_ref(), prompt, images) { - (Some(ExecCommand::Review(review_cli)), _, _) => { - let review_request = build_review_request(review_cli)?; - let summary = codex_core::review_prompts::user_facing_hint(&review_request.target); - (InitialOperation::Review { review_request }, summary) - } - (Some(ExecCommand::Resume(args)), root_prompt, imgs) => { - let prompt_arg = args - .prompt - .clone() - .or_else(|| { - if args.last { - args.session_id.clone() - } else { - None - } - }) - .or(root_prompt); - let prompt_text = resolve_prompt(prompt_arg); - let mut items: Vec = imgs - .into_iter() - .chain(args.images.iter().cloned()) - .map(|path| UserInput::LocalImage { path }) - .collect(); - items.push(UserInput::Text { - text: prompt_text.clone(), - // CLI input doesn't track UI element ranges, so none are available here. - text_elements: Vec::new(), - }); - let output_schema = load_output_schema(output_schema_path.clone()); - ( - InitialOperation::UserTurn { - items, - output_schema, - }, - prompt_text, - ) - } - (None, root_prompt, imgs) => { - let prompt_text = resolve_root_prompt(root_prompt); - let mut items: Vec = imgs - .into_iter() - .map(|path| UserInput::LocalImage { path }) - .collect(); - items.push(UserInput::Text { - text: prompt_text.clone(), - // CLI input doesn't track UI element ranges, so none are available here. - text_elements: Vec::new(), - }); - let output_schema = load_output_schema(output_schema_path); - ( - InitialOperation::UserTurn { - items, - output_schema, - }, - prompt_text, - ) - } - }; - // Print the effective configuration and initial request so users can see what Codex // is using. event_processor.print_config_summary(&config, &prompt_summary, &session_configured); diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 4746ed20d0..0af5cc5250 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; @@ -303,6 +309,9 @@ fn should_backfill_turn_completed_items_skips_ephemeral_threads() { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }); diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..c22207c914 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/features/Cargo.toml b/codex-rs/features/Cargo.toml index add5296d8c..95be6c400d 100644 --- a/codex-rs/features/Cargo.toml +++ b/codex-rs/features/Cargo.toml @@ -13,7 +13,6 @@ path = "src/lib.rs" workspace = true [dependencies] -codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } schemars = { workspace = true } diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index c87b6dc9ff..f49e0edc05 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -3,8 +3,6 @@ //! This crate defines the feature registry plus the logic used to resolve an //! effective feature set from config-like inputs. -use codex_login::AuthManager; -use codex_login::CodexAuth; use codex_otel::SessionTelemetry; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -176,6 +174,8 @@ pub enum Feature { FastMode, /// Enable experimental realtime voice conversation mode in the TUI. RealtimeConversation, + /// Connect app-server to the ChatGPT remote control service. + RemoteControl, /// Removed compatibility flag. The TUI now always uses the app-server implementation. TuiAppServer, /// Prevent idle system sleep while a turn is actively running. @@ -273,25 +273,8 @@ impl Features { self.enabled.contains(&f) } - pub async fn apps_enabled(&self, auth_manager: Option<&AuthManager>) -> bool { - if !self.enabled(Feature::Apps) { - return false; - } - - let auth = match auth_manager { - Some(auth_manager) => auth_manager.auth().await, - None => None, - }; - self.apps_enabled_for_auth(auth.as_ref()) - } - - pub fn apps_enabled_cached(&self, auth_manager: Option<&AuthManager>) -> bool { - let auth = auth_manager.and_then(AuthManager::auth_cached); - self.apps_enabled_for_auth(auth.as_ref()) - } - - pub fn apps_enabled_for_auth(&self, auth: Option<&CodexAuth>) -> bool { - self.enabled(Feature::Apps) && auth.is_some_and(CodexAuth::is_chatgpt_auth) + pub fn apps_enabled_for_auth(&self, has_chatgpt_auth: bool) -> bool { + self.enabled(Feature::Apps) && has_chatgpt_auth } pub fn use_legacy_landlock(&self) -> bool { @@ -638,7 +621,11 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ImageDetailOriginal, key: "image_detail_original", - stage: Stage::UnderDevelopment, + stage: Stage::Experimental { + name: "Original image detail", + menu_description: "Let the model inspect tool-emitted images at full resolution on supported models instead of a resized approximation. This affects tool-emitted images such as those produced by `view_image`, not images attached directly in the UI. It is particularly important for localization and precise UI targeting, for reading small text, and for reasoning about precise layout.", + announcement: "NEW: Original image detail is now available in /experimental. Enable it to let tools request full-resolution image detail on supported models for CUA and localization tasks.", + }, default_enabled: false, }, FeatureSpec { @@ -825,6 +812,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::RemoteControl, + key: "remote_control", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::TuiAppServer, key: "tui_app_server", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 0777262065..23653fadcd 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -157,12 +157,9 @@ fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { } #[test] -fn image_detail_original_feature_is_under_development() { - assert_eq!( - Feature::ImageDetailOriginal.stage(), - Stage::UnderDevelopment - ); - assert_eq!(Feature::ImageDetailOriginal.default_enabled(), false); +fn remote_control_is_under_development() { + assert_eq!(Feature::RemoteControl.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::RemoteControl.default_enabled(), false); } #[test] @@ -201,16 +198,11 @@ fn enable_fanout_normalization_enables_multi_agent_one_way() { #[test] fn apps_require_feature_flag_and_chatgpt_auth() { let mut features = Features::with_defaults(); - assert!(!features.apps_enabled_for_auth(/*auth*/ None)); + assert!(!features.apps_enabled_for_auth(/*has_chatgpt_auth*/ false)); features.enable(Feature::Apps); - assert!(!features.apps_enabled_for_auth(/*auth*/ None)); - - let api_key_auth = codex_login::CodexAuth::from_api_key("test-api-key"); - assert!(!features.apps_enabled_for_auth(Some(&api_key_auth))); - - let chatgpt_auth = codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(); - assert!(features.apps_enabled_for_auth(Some(&chatgpt_auth))); + assert!(!features.apps_enabled_for_auth(/*has_chatgpt_auth*/ false)); + assert!(features.apps_enabled_for_auth(/*has_chatgpt_auth*/ true)); } #[test] diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 524c574fb0..751d6d5d17 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -17,8 +17,6 @@ workspace = true [target.'cfg(target_os = "linux")'.dependencies] clap = { workspace = true, features = ["derive"] } -codex-config = { workspace = true } -codex-core = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } codex-utils-absolute-path = { workspace = true } @@ -30,6 +28,8 @@ serde_json = { workspace = true } url = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] +codex-config = { workspace = true } +codex-core = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index d8af1959a4..340b109101 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -15,7 +15,8 @@ no-`--argv0` compatibility path for the inner re-exec. If `bwrap` is missing, the helper falls back to the vendored bubblewrap path compiled into this binary. Codex also surfaces a startup warning when `bwrap` is missing so users know it -is falling back to the vendored helper. +is falling back to the vendored helper. Codex surfaces the same startup warning +path when bubblewrap cannot create user namespaces. **Current Behavior** - Legacy `SandboxPolicy` / `sandbox_mode` configs remain supported. @@ -28,6 +29,8 @@ is falling back to the vendored helper. path. - If `bwrap` is missing, Codex also surfaces a startup warning instead of printing directly from the sandbox helper. +- If bubblewrap cannot create user namespaces, Codex surfaces a startup warning + instead of waiting for a runtime sandbox failure. - Legacy Landlock + mount protections remain available as an explicit legacy fallback path. - Set `features.use_legacy_landlock = true` (or CLI `-c use_legacy_landlock=true`) diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 459d76c383..9c2d021c76 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -25,7 +25,6 @@ once_cell = { workspace = true } os_info = { workspace = true } rand = { workspace = true } reqwest = { workspace = true, features = ["json", "blocking"] } -schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 9f58f0f090..71857c9700 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -20,7 +20,6 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; use super::external_bearer::BearerTokenRefresher; -pub use crate::auth::storage::AuthCredentialsStoreMode; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; @@ -30,6 +29,7 @@ use crate::token_data::TokenData; use crate::token_data::parse_chatgpt_jwt_claims; use crate::token_data::parse_jwt_expiration; use codex_client::CodexHttpClient; +use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::KnownPlan as InternalKnownPlan; use codex_protocol::auth::PlanType as InternalPlanType; @@ -1108,6 +1108,23 @@ pub struct AuthManager { external_auth: RwLock>>, } +/// Configuration view required to construct a shared [`AuthManager`]. +/// +/// Implementations should return the auth-related config values for the +/// already-resolved runtime configuration. The primary implementation is +/// `codex_core::config::Config`, but this trait keeps `codex-login` independent +/// from `codex-core`. +pub trait AuthManagerConfig { + /// Returns the Codex home directory used for auth storage. + fn codex_home(&self) -> PathBuf; + + /// Returns the CLI auth credential storage mode for auth loading. + fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode; + + /// Returns the workspace ID that ChatGPT auth should be restricted to, if any. + fn forced_chatgpt_workspace_id(&self) -> Option; +} + impl Debug for AuthManager { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AuthManager") @@ -1404,19 +1421,18 @@ impl AuthManager { )) } - pub fn shared_with_external_auth( - codex_home: PathBuf, + /// Convenience constructor returning an `Arc` wrapper from resolved config. + pub fn shared_from_config( + config: &impl AuthManagerConfig, enable_codex_api_key_env: bool, - auth_credentials_store_mode: AuthCredentialsStoreMode, - external_auth: Arc, ) -> Arc { - let manager = Self::shared( - codex_home, + let auth_manager = Self::shared( + config.codex_home(), enable_codex_api_key_env, - auth_credentials_store_mode, + config.cli_auth_credentials_store_mode(), ); - manager.set_external_auth(external_auth); - manager + auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id()); + auth_manager } pub fn unauthorized_recovery(self: &Arc) -> UnauthorizedRecovery { diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index b1e04b8685..97e801415c 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -1,6 +1,5 @@ use chrono::DateTime; use chrono::Utc; -use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use sha2::Digest; @@ -21,25 +20,11 @@ use tracing::warn; use crate::token_data::TokenData; use codex_app_server_protocol::AuthMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_keyring_store::DefaultKeyringStore; use codex_keyring_store::KeyringStore; use once_cell::sync::Lazy; -/// Determine where Codex should store CLI auth credentials. -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum AuthCredentialsStoreMode { - #[default] - /// Persist credentials in CODEX_HOME/auth.json. - File, - /// Persist credentials in the keyring. Fail if unavailable. - Keyring, - /// Use keyring when available; otherwise, fall back to a file in CODEX_HOME. - Auto, - /// Store credentials in memory only for the current process. - Ephemeral, -} - /// Expected structure for $CODEX_HOME/auth.json. #[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct AuthDotJson { diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index ad77d82389..f786884b34 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -20,9 +20,9 @@ pub use server::run_login_server; pub use api_bridge::auth_provider_from_auth; pub use auth::AuthConfig; -pub use auth::AuthCredentialsStoreMode; pub use auth::AuthDotJson; pub use auth::AuthManager; +pub use auth::AuthManagerConfig; pub use auth::CLIENT_ID; pub use auth::CODEX_API_KEY_ENV_VAR; pub use auth::CodexAuth; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index c811fa36df..169a8a3091 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -24,7 +24,6 @@ use std::sync::LazyLock; use std::thread; use std::time::Duration; -use crate::auth::AuthCredentialsStoreMode; use crate::auth::AuthDotJson; use crate::auth::save_auth; use crate::default_client::originator; @@ -36,6 +35,7 @@ use base64::Engine; use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_client::build_reqwest_client_with_custom_ca; +use codex_config::types::AuthCredentialsStoreMode; use codex_utils_template::Template; use rand::RngCore; use serde_json::Value as JsonValue; diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index 94ba8220e3..bf9e03bc26 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -4,7 +4,7 @@ use base64::Engine; use chrono::Duration; use chrono::Utc; use codex_app_server_protocol::AuthMode; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; use codex_login::AuthManager; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 80c4fc0e56..bed94c7005 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -3,8 +3,8 @@ use anyhow::Context; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::ServerOptions; -use codex_login::auth::AuthCredentialsStoreMode; use codex_login::auth::load_auth_dot_json; use codex_login::run_device_code_login; use serde_json::json; diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 5b0ddd9b72..9522f5b0b0 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -7,8 +7,8 @@ use std::time::Duration; use anyhow::Result; use base64::Engine; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::ServerOptions; -use codex_login::auth::AuthCredentialsStoreMode; use codex_login::run_login_server; use core_test_support::skip_if_no_network; use tempfile::tempdir; diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 29de014d4a..9deadea81b 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -25,7 +25,6 @@ codex-features = { workspace = true } codex-login = { workspace = true } codex-models-manager = { workspace = true } codex-protocol = { workspace = true } -codex-shell-command = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-json-to-toml = { workspace = true } rmcp = { workspace = true } @@ -44,6 +43,7 @@ tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } [dev-dependencies] +codex-shell-command = { workspace = true } core_test_support = { workspace = true } mcp_test_support = { workspace = true } os_info = { workspace = true } diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 2596339acd..ccaaba3cd7 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; use codex_exec_server::EnvironmentManager; +use codex_login::default_client::set_default_client_residency_requirement; use codex_utils_cli::CliConfigOverrides; use rmcp::model::ClientNotification; @@ -71,6 +72,7 @@ pub async fn run_main( .map_err(|e| { std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) })?; + set_default_client_residency_requirement(config.enforce_residency.value()); let otel = codex_core::otel_init::build_provider( &config, diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index a0d4321173..46470f994a 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -56,10 +56,9 @@ impl MessageProcessor { environment_manager: Arc, ) -> Self { let outgoing = Arc::new(outgoing); - let auth_manager = AuthManager::shared( - config.codex_home.clone(), + let auth_manager = AuthManager::shared_from_config( + config.as_ref(), /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), diff --git a/codex-rs/models-manager/Cargo.toml b/codex-rs/models-manager/Cargo.toml index fbd5d5f3e8..58eff2437a 100644 --- a/codex-rs/models-manager/Cargo.toml +++ b/codex-rs/models-manager/Cargo.toml @@ -17,6 +17,7 @@ chrono = { workspace = true, features = ["serde"] } codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-collaboration-mode-templates = { workspace = true } +codex-config = { workspace = true } codex-feedback = { workspace = true } codex-login = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/models-manager/src/lib.rs b/codex-rs/models-manager/src/lib.rs index 5f26b37f89..a9c0d489d3 100644 --- a/codex-rs/models-manager/src/lib.rs +++ b/codex-rs/models-manager/src/lib.rs @@ -6,7 +6,6 @@ pub mod model_info; pub mod model_presets; pub use codex_app_server_protocol::AuthMode; -pub use codex_login::AuthCredentialsStoreMode; pub use codex_login::AuthManager; pub use codex_login::CodexAuth; pub use codex_model_provider_info::ModelProviderInfo; diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index b955786cc5..23de81781a 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -3,7 +3,7 @@ use crate::ModelsManagerConfig; use base64::Engine as _; use chrono::Utc; use codex_api::TransportError; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_model_provider_info::WireApi; diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index d2a8b0ccd8..f6e69743b9 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -82,6 +82,38 @@ pub struct Resource { pub meta: Option, } +/// Contents returned when reading a resource from an MCP server. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(untagged)] +pub enum ResourceContent { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Text { + /// The URI of this resource. + uri: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + mime_type: Option, + text: String, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + meta: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Blob { + /// The URI of this resource. + uri: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + mime_type: Option, + blob: String, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + meta: Option, + }, +} + /// A template description for resources available on the server. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7116878b47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 4b20e9d6eb..aa5ab5eee6 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -14,6 +14,7 @@ axum = { workspace = true, default-features = false, features = [ "tokio", ] } codex-client = { workspace = true } +codex-config = { workspace = true } codex-keyring-store = { workspace = true } codex-protocol = { workspace = true } codex-utils-pty = { workspace = true } @@ -37,7 +38,6 @@ rmcp = { workspace = true, default-features = false, features = [ "transport-streamable-http-client-reqwest", "transport-streamable-http-server", ] } -schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/codex-rs/rmcp-client/src/auth_status.rs b/codex-rs/rmcp-client/src/auth_status.rs index f97898548c..0b3b3bf6a7 100644 --- a/codex-rs/rmcp-client/src/auth_status.rs +++ b/codex-rs/rmcp-client/src/auth_status.rs @@ -12,10 +12,10 @@ use reqwest::header::HeaderMap; use serde::Deserialize; use tracing::debug; -use crate::OAuthCredentialsStoreMode; use crate::oauth::has_oauth_tokens; use crate::utils::apply_default_headers; use crate::utils::build_default_headers; +use codex_config::types::OAuthCredentialsStoreMode; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); const OAUTH_DISCOVERY_HEADER: &str = "MCP-Protocol-Version"; diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index 627b9f2e71..65b9cf8e28 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -11,7 +11,6 @@ pub use auth_status::determine_streamable_http_auth_status; pub use auth_status::discover_streamable_http_oauth; pub use auth_status::supports_oauth_login; pub use codex_protocol::protocol::McpAuthStatus; -pub use oauth::OAuthCredentialsStoreMode; pub use oauth::StoredOAuthTokens; pub use oauth::WrappedOAuthTokenResponse; pub use oauth::delete_oauth_tokens; diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index cdb64ff151..ddabffe29f 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -19,6 +19,7 @@ use anyhow::Context; use anyhow::Error; use anyhow::Result; +use codex_config::types::OAuthCredentialsStoreMode; use oauth2::AccessToken; use oauth2::EmptyExtraTokenFields; use oauth2::RefreshToken; @@ -26,7 +27,6 @@ use oauth2::Scope; use oauth2::TokenResponse; use oauth2::basic::BasicTokenType; use rmcp::transport::auth::OAuthTokenResponse; -use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -63,21 +63,6 @@ pub struct StoredOAuthTokens { pub expires_at: Option, } -/// Determine where Codex should store and read MCP credentials. -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum OAuthCredentialsStoreMode { - /// `Keyring` when available; otherwise, `File`. - /// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access. - #[default] - Auto, - /// CODEX_HOME/.credentials.json - /// This file will be readable to Codex and other applications running as the same user. - File, - /// Keyring when available, otherwise fail. - Keyring, -} - /// Wrap OAuthTokenResponse to allow for partial equality comparison. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WrappedOAuthTokenResponse(pub OAuthTokenResponse); diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index 821fbf2750..5bdb315381 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -16,13 +16,13 @@ use tokio::sync::oneshot; use tokio::time::timeout; use urlencoding::decode; -use crate::OAuthCredentialsStoreMode; use crate::StoredOAuthTokens; use crate::WrappedOAuthTokenResponse; use crate::oauth::compute_expires_at_millis; use crate::save_oauth_tokens; use crate::utils::apply_default_headers; use crate::utils::build_default_headers; +use codex_config::types::OAuthCredentialsStoreMode; struct OauthHeaders { http_headers: Option>, diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index aa460c21b9..d3316a0491 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -69,13 +69,13 @@ use tracing::warn; use crate::load_oauth_tokens; use crate::logging_client_handler::LoggingClientHandler; -use crate::oauth::OAuthCredentialsStoreMode; use crate::oauth::OAuthPersistor; use crate::oauth::StoredOAuthTokens; use crate::program_resolver; use crate::utils::apply_default_headers; use crate::utils::build_default_headers; use crate::utils::create_env_for_mcp_server; +use codex_config::types::OAuthCredentialsStoreMode; const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; const JSON_MIME_TYPE: &str = "application/json"; diff --git a/codex-rs/rmcp-client/tests/streamable_http_recovery.rs b/codex-rs/rmcp-client/tests/streamable_http_recovery.rs index 6a75582c97..c0525aafaf 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_recovery.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_recovery.rs @@ -3,9 +3,9 @@ use std::path::PathBuf; use std::time::Duration; use std::time::Instant; +use codex_config::types::OAuthCredentialsStoreMode; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; -use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_rmcp_client::RmcpClient; use codex_utils_cargo_bin::CargoBinError; use futures::FutureExt as _; diff --git a/codex-rs/sandboxing/src/bwrap.rs b/codex-rs/sandboxing/src/bwrap.rs index aa73af1d3e..01d53ebbe8 100644 --- a/codex-rs/sandboxing/src/bwrap.rs +++ b/codex-rs/sandboxing/src/bwrap.rs @@ -1,15 +1,33 @@ use codex_protocol::protocol::SandboxPolicy; use std::path::Path; use std::path::PathBuf; +use std::process::Command; +use std::process::Output; const SYSTEM_BWRAP_PROGRAM: &str = "bwrap"; +const MISSING_BWRAP_WARNING: &str = concat!( + "Codex could not find bubblewrap on PATH. ", + "Install bubblewrap with your OS package manager. ", + "See the sandbox prerequisites: ", + "https://developers.openai.com/codex/concepts/sandboxing#prerequisites. ", + "Codex will use the vendored bubblewrap in the meantime.", +); +const USER_NAMESPACE_WARNING: &str = + "Codex's Linux sandbox uses bubblewrap and needs access to create user namespaces."; +const USER_NAMESPACE_FAILURES: [&str; 4] = [ + "loopback: Failed RTM_NEWADDR", + "loopback: Failed RTM_NEWLINK", + "setting up uid map: Permission denied", + "No permissions to create a new namespace", +]; pub fn system_bwrap_warning(sandbox_policy: &SandboxPolicy) -> Option { if !should_warn_about_system_bwrap(sandbox_policy) { return None; } - system_bwrap_warning_for_lookup(find_system_bwrap_in_path()) + let system_bwrap_path = find_system_bwrap_in_path(); + system_bwrap_warning_for_path(system_bwrap_path.as_deref()) } fn should_warn_about_system_bwrap(sandbox_policy: &SandboxPolicy) -> bool { @@ -19,14 +37,42 @@ fn should_warn_about_system_bwrap(sandbox_policy: &SandboxPolicy) -> bool { ) } -fn system_bwrap_warning_for_lookup(system_bwrap_path: Option) -> Option { - match system_bwrap_path { - Some(_) => None, - None => Some( - "Codex could not find system bubblewrap on PATH. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime." - .to_string(), - ), +fn system_bwrap_warning_for_path(system_bwrap_path: Option<&Path>) -> Option { + let Some(system_bwrap_path) = system_bwrap_path else { + return Some(MISSING_BWRAP_WARNING.to_string()); + }; + + if !system_bwrap_has_user_namespace_access(system_bwrap_path) { + return Some(USER_NAMESPACE_WARNING.to_string()); } + + None +} + +fn system_bwrap_has_user_namespace_access(system_bwrap_path: &Path) -> bool { + let output = match Command::new(system_bwrap_path) + .args([ + "--unshare-user", + "--unshare-net", + "--ro-bind", + "/", + "/", + "/bin/true", + ]) + .output() + { + Ok(output) => output, + Err(_) => return true, + }; + + output.status.success() || !is_user_namespace_failure(&output) +} + +fn is_user_namespace_failure(output: &Output) -> bool { + let stderr = String::from_utf8_lossy(&output.stderr); + USER_NAMESPACE_FAILURES + .iter() + .any(|failure| stderr.contains(failure)) } pub fn find_system_bwrap_in_path() -> Option { diff --git a/codex-rs/sandboxing/src/bwrap_tests.rs b/codex-rs/sandboxing/src/bwrap_tests.rs index 43eddb39c4..b0303e2b78 100644 --- a/codex-rs/sandboxing/src/bwrap_tests.rs +++ b/codex-rs/sandboxing/src/bwrap_tests.rs @@ -6,30 +6,42 @@ use tempfile::tempdir; #[test] fn system_bwrap_warning_reports_missing_system_bwrap() { - let warning = system_bwrap_warning_for_lookup(/*system_bwrap_path*/ None) - .expect("missing system bwrap should emit a warning"); - - assert!(warning.contains("could not find system bubblewrap")); + assert_eq!( + system_bwrap_warning_for_path(/*system_bwrap_path*/ None), + Some(MISSING_BWRAP_WARNING.to_string()) + ); } #[test] -fn system_bwrap_warning_skips_too_old_system_bwrap() { +fn system_bwrap_warning_reports_user_namespace_failures() { + for failure in USER_NAMESPACE_FAILURES { + let fake_bwrap = write_fake_bwrap(&format!( + r#"#!/bin/sh +echo '{failure}' >&2 +exit 1 +"# + )); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + + assert_eq!( + system_bwrap_warning_for_path(Some(fake_bwrap_path)), + Some(USER_NAMESPACE_WARNING.to_string()), + "{failure}", + ); + } +} + +#[test] +fn system_bwrap_warning_skips_unrelated_bwrap_failures() { let fake_bwrap = write_fake_bwrap( r#"#!/bin/sh -if [ "$1" = "--help" ]; then - echo 'usage: bwrap [OPTION...] COMMAND' - exit 0 -fi +echo 'bwrap: Unknown option --argv0' >&2 exit 1 "#, ); let fake_bwrap_path: &Path = fake_bwrap.as_ref(); - assert_eq!( - system_bwrap_warning_for_lookup(Some(fake_bwrap_path.to_path_buf())), - None, - "Do not warn even if bwrap does not support `--argv0`", - ); + assert_eq!(system_bwrap_warning_for_path(Some(fake_bwrap_path)), None); } #[test] @@ -102,5 +114,5 @@ fn write_named_fake_bwrap_in(dir: &Path) -> PathBuf { fs::write(&path, "#!/bin/sh\n").expect("write fake bwrap"); let permissions = fs::Permissions::from_mode(0o755); fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); - path + fs::canonicalize(path).expect("canonicalize fake bwrap") } diff --git a/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl b/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl index 8d80277f6c..99f43e42e3 100644 --- a/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl +++ b/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl @@ -92,6 +92,12 @@ ; Needed for python multiprocessing on MacOS for the SemLock (allow ipc-posix-sem) +; Needed for PyTorch/libomp on macOS to register OpenMP runtimes. +(allow ipc-posix-shm-read-data + ipc-posix-shm-write-create + ipc-posix-shm-write-unlink + (ipc-posix-name-regex #"^/__KMP_REGISTERED_LIB_[0-9]+$")) + (allow mach-lookup (global-name "com.apple.PowerManagement.control") ) diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index d2ac67c0e1..0daff82e15 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -60,6 +60,19 @@ fn base_policy_allows_node_cpu_sysctls() { ); } +#[test] +fn base_policy_allows_kmp_registration_shm_read_create_and_unlink() { + let expected = r##"(allow ipc-posix-shm-read-data + ipc-posix-shm-write-create + ipc-posix-shm-write-unlink + (ipc-posix-name-regex #"^/__KMP_REGISTERED_LIB_[0-9]+$"))"##; + + assert!( + MACOS_SEATBELT_BASE_POLICY.contains(expected), + "base policy must allow only KMP registration shm read/create/unlink:\n{MACOS_SEATBELT_BASE_POLICY}" + ); +} + #[test] fn create_seatbelt_args_routes_network_through_proxy_ports() { let policy = dynamic_network_policy( diff --git a/codex-rs/state/migrations/0024_remote_control_enrollments.sql b/codex-rs/state/migrations/0024_remote_control_enrollments.sql new file mode 100644 index 0000000000..970db9ef20 --- /dev/null +++ b/codex-rs/state/migrations/0024_remote_control_enrollments.sql @@ -0,0 +1,10 @@ +CREATE TABLE remote_control_enrollments ( + websocket_url TEXT NOT NULL, + account_id TEXT NOT NULL, + app_server_client_name TEXT NOT NULL, + server_id TEXT NOT NULL, + environment_id TEXT NOT NULL, + server_name TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (websocket_url, account_id, app_server_client_name) +); diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index ffaa1637e2..efad7651f5 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -46,6 +46,7 @@ pub use model::Stage1StartupClaimParams; pub use model::ThreadMetadata; pub use model::ThreadMetadataBuilder; pub use model::ThreadsPage; +pub use runtime::RemoteControlEnrollmentRecord; pub use runtime::logs_db_filename; pub use runtime::logs_db_path; pub use runtime::state_db_filename; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index d3d81d87d3..f71b6adf05 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -54,10 +54,13 @@ mod agent_jobs; mod backfill; mod logs; mod memories; +mod remote_control; #[cfg(test)] mod test_support; mod threads; +pub use remote_control::RemoteControlEnrollmentRecord; + // "Partition" is the retained-log-content bucket we cap at 10 MiB: // - one bucket per non-null thread_id // - one bucket per threadless (thread_id IS NULL) non-null process_uuid diff --git a/codex-rs/state/src/runtime/remote_control.rs b/codex-rs/state/src/runtime/remote_control.rs new file mode 100644 index 0000000000..fa0b1823f8 --- /dev/null +++ b/codex-rs/state/src/runtime/remote_control.rs @@ -0,0 +1,283 @@ +use super::*; + +const REMOTE_CONTROL_APP_SERVER_CLIENT_NAME_NONE: &str = ""; + +/// Persisted remote-control server enrollment, including the lookup key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteControlEnrollmentRecord { + pub websocket_url: String, + pub account_id: String, + pub app_server_client_name: Option, + pub server_id: String, + pub environment_id: String, + pub server_name: String, +} + +fn remote_control_app_server_client_name_key(app_server_client_name: Option<&str>) -> &str { + app_server_client_name.unwrap_or(REMOTE_CONTROL_APP_SERVER_CLIENT_NAME_NONE) +} + +fn app_server_client_name_from_key(app_server_client_name: String) -> Option { + if app_server_client_name.is_empty() { + None + } else { + Some(app_server_client_name) + } +} + +impl StateRuntime { + pub async fn get_remote_control_enrollment( + &self, + websocket_url: &str, + account_id: &str, + app_server_client_name: Option<&str>, + ) -> anyhow::Result> { + let row = sqlx::query( + r#" +SELECT websocket_url, account_id, app_server_client_name, server_id, environment_id, server_name +FROM remote_control_enrollments +WHERE websocket_url = ? AND account_id = ? AND app_server_client_name = ? + "#, + ) + .bind(websocket_url) + .bind(account_id) + .bind(remote_control_app_server_client_name_key( + app_server_client_name, + )) + .fetch_optional(self.pool.as_ref()) + .await?; + + row.map(|row| { + let app_server_client_name: String = row.try_get("app_server_client_name")?; + Ok(RemoteControlEnrollmentRecord { + websocket_url: row.try_get("websocket_url")?, + account_id: row.try_get("account_id")?, + app_server_client_name: app_server_client_name_from_key(app_server_client_name), + server_id: row.try_get("server_id")?, + environment_id: row.try_get("environment_id")?, + server_name: row.try_get("server_name")?, + }) + }) + .transpose() + } + + pub async fn upsert_remote_control_enrollment( + &self, + enrollment: &RemoteControlEnrollmentRecord, + ) -> anyhow::Result<()> { + sqlx::query( + r#" +INSERT INTO remote_control_enrollments ( + websocket_url, + account_id, + app_server_client_name, + server_id, + environment_id, + server_name, + updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(websocket_url, account_id, app_server_client_name) DO UPDATE SET + server_id = excluded.server_id, + environment_id = excluded.environment_id, + server_name = excluded.server_name, + updated_at = excluded.updated_at + "#, + ) + .bind(&enrollment.websocket_url) + .bind(&enrollment.account_id) + .bind(remote_control_app_server_client_name_key( + enrollment.app_server_client_name.as_deref(), + )) + .bind(&enrollment.server_id) + .bind(&enrollment.environment_id) + .bind(&enrollment.server_name) + .bind(Utc::now().timestamp()) + .execute(self.pool.as_ref()) + .await?; + Ok(()) + } + + pub async fn delete_remote_control_enrollment( + &self, + websocket_url: &str, + account_id: &str, + app_server_client_name: Option<&str>, + ) -> anyhow::Result { + let result = sqlx::query( + r#" +DELETE FROM remote_control_enrollments +WHERE websocket_url = ? AND account_id = ? AND app_server_client_name = ? + "#, + ) + .bind(websocket_url) + .bind(account_id) + .bind(remote_control_app_server_client_name_key( + app_server_client_name, + )) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected()) + } +} + +#[cfg(test)] +mod tests { + use super::RemoteControlEnrollmentRecord; + use super::StateRuntime; + use super::test_support::unique_temp_dir; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn remote_control_enrollment_round_trips_by_target_and_account() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + + runtime + .upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord { + websocket_url: "wss://example.com/backend-api/wham/remote/control/server" + .to_string(), + account_id: "account-a".to_string(), + app_server_client_name: Some("desktop-client".to_string()), + server_id: "srv_e_first".to_string(), + environment_id: "env_first".to_string(), + server_name: "first-server".to_string(), + }) + .await + .expect("insert first enrollment"); + runtime + .upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord { + websocket_url: "wss://example.com/backend-api/wham/remote/control/server" + .to_string(), + account_id: "account-b".to_string(), + app_server_client_name: Some("desktop-client".to_string()), + server_id: "srv_e_second".to_string(), + environment_id: "env_second".to_string(), + server_name: "second-server".to_string(), + }) + .await + .expect("insert second enrollment"); + + assert_eq!( + runtime + .get_remote_control_enrollment( + "wss://example.com/backend-api/wham/remote/control/server", + "account-a", + Some("desktop-client"), + ) + .await + .expect("load first enrollment"), + Some(RemoteControlEnrollmentRecord { + websocket_url: "wss://example.com/backend-api/wham/remote/control/server" + .to_string(), + account_id: "account-a".to_string(), + app_server_client_name: Some("desktop-client".to_string()), + server_id: "srv_e_first".to_string(), + environment_id: "env_first".to_string(), + server_name: "first-server".to_string(), + }) + ); + assert_eq!( + runtime + .get_remote_control_enrollment( + "wss://example.com/backend-api/wham/remote/control/server", + "account-missing", + Some("desktop-client"), + ) + .await + .expect("load missing enrollment"), + None + ); + assert_eq!( + runtime + .get_remote_control_enrollment( + "wss://example.com/backend-api/wham/remote/control/server", + "account-a", + Some("other-client"), + ) + .await + .expect("load wrong client enrollment"), + None + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn delete_remote_control_enrollment_removes_only_matching_entry() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + + runtime + .upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord { + websocket_url: "wss://example.com/backend-api/wham/remote/control/server" + .to_string(), + account_id: "account-a".to_string(), + app_server_client_name: None, + server_id: "srv_e_first".to_string(), + environment_id: "env_first".to_string(), + server_name: "first-server".to_string(), + }) + .await + .expect("insert first enrollment"); + runtime + .upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord { + websocket_url: "wss://example.com/backend-api/wham/remote/control/server" + .to_string(), + account_id: "account-b".to_string(), + app_server_client_name: None, + server_id: "srv_e_second".to_string(), + environment_id: "env_second".to_string(), + server_name: "second-server".to_string(), + }) + .await + .expect("insert second enrollment"); + + assert_eq!( + runtime + .delete_remote_control_enrollment( + "wss://example.com/backend-api/wham/remote/control/server", + "account-a", + /*app_server_client_name*/ None, + ) + .await + .expect("delete first enrollment"), + 1 + ); + assert_eq!( + runtime + .get_remote_control_enrollment( + "wss://example.com/backend-api/wham/remote/control/server", + "account-a", + /*app_server_client_name*/ None, + ) + .await + .expect("load deleted enrollment"), + None + ); + assert_eq!( + runtime + .get_remote_control_enrollment( + "wss://example.com/backend-api/wham/remote/control/server", + "account-b", + /*app_server_client_name*/ None, + ) + .await + .expect("load retained enrollment"), + Some(RemoteControlEnrollmentRecord { + websocket_url: "wss://example.com/backend-api/wham/remote/control/server" + .to_string(), + account_id: "account-b".to_string(), + app_server_client_name: None, + server_id: "srv_e_second".to_string(), + environment_id: "env_second".to_string(), + server_name: "second-server".to_string(), + }) + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } +} diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index f48f1b8fcc..6e9e0d1f57 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -86,6 +86,7 @@ pub struct ToolsConfig { pub shell_type: ConfigShellToolType, pub shell_command_backend: ShellCommandBackendConfig, pub unified_exec_shell_mode: UnifiedExecShellMode, + pub has_environment: bool, pub allow_login_shell: bool, pub apply_patch_tool_type: Option, pub web_search_mode: Option, @@ -200,6 +201,7 @@ impl ToolsConfig { shell_type, shell_command_backend, unified_exec_shell_mode: UnifiedExecShellMode::Direct, + has_environment: true, allow_login_shell: true, apply_patch_tool_type, web_search_mode: *web_search_mode, @@ -236,6 +238,11 @@ impl ToolsConfig { self } + pub fn with_has_environment(mut self, has_environment: bool) -> Self { + self.has_environment = has_environment; + self + } + pub fn with_unified_exec_shell_mode( mut self, unified_exec_shell_mode: UnifiedExecShellMode, diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index cc706f24fc..8985e2ee87 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -107,54 +107,56 @@ pub fn build_tool_registry_plan( ); } - match &config.shell_type { - ConfigShellToolType::Default => { - plan.push_spec( - create_shell_tool(ShellToolOptions { - exec_permission_approvals_enabled, - }), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); - } - ConfigShellToolType::Local => { - plan.push_spec( - create_local_shell_tool(), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); - } - ConfigShellToolType::UnifiedExec => { - plan.push_spec( - create_exec_command_tool(CommandToolOptions { - allow_login_shell: config.allow_login_shell, - exec_permission_approvals_enabled, - }), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); - plan.push_spec( - create_write_stdin_tool(), - /*supports_parallel_tool_calls*/ false, - config.code_mode_enabled, - ); - plan.register_handler("exec_command", ToolHandlerKind::UnifiedExec); - plan.register_handler("write_stdin", ToolHandlerKind::UnifiedExec); - } - ConfigShellToolType::Disabled => {} - ConfigShellToolType::ShellCommand => { - plan.push_spec( - create_shell_command_tool(CommandToolOptions { - allow_login_shell: config.allow_login_shell, - exec_permission_approvals_enabled, - }), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); + if config.has_environment { + match &config.shell_type { + ConfigShellToolType::Default => { + plan.push_spec( + create_shell_tool(ShellToolOptions { + exec_permission_approvals_enabled, + }), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + } + ConfigShellToolType::Local => { + plan.push_spec( + create_local_shell_tool(), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + } + ConfigShellToolType::UnifiedExec => { + plan.push_spec( + create_exec_command_tool(CommandToolOptions { + allow_login_shell: config.allow_login_shell, + exec_permission_approvals_enabled, + }), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + plan.push_spec( + create_write_stdin_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + plan.register_handler("exec_command", ToolHandlerKind::UnifiedExec); + plan.register_handler("write_stdin", ToolHandlerKind::UnifiedExec); + } + ConfigShellToolType::Disabled => {} + ConfigShellToolType::ShellCommand => { + plan.push_spec( + create_shell_command_tool(CommandToolOptions { + allow_login_shell: config.allow_login_shell, + exec_permission_approvals_enabled, + }), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + } } } - if config.shell_type != ConfigShellToolType::Disabled { + if config.has_environment && config.shell_type != ConfigShellToolType::Disabled { plan.register_handler("shell", ToolHandlerKind::Shell); plan.register_handler("container.exec", ToolHandlerKind::Shell); plan.register_handler("local_shell", ToolHandlerKind::Shell); @@ -189,7 +191,7 @@ pub fn build_tool_registry_plan( ); plan.register_handler("update_plan", ToolHandlerKind::Plan); - if config.js_repl_enabled { + if config.has_environment && config.js_repl_enabled { plan.push_spec( create_js_repl_tool(), /*supports_parallel_tool_calls*/ false, @@ -265,7 +267,9 @@ pub fn build_tool_registry_plan( plan.register_handler(TOOL_SUGGEST_TOOL_NAME, ToolHandlerKind::ToolSuggest); } - if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { + if config.has_environment + && let Some(apply_patch_tool_type) = &config.apply_patch_tool_type + { match apply_patch_tool_type { ApplyPatchToolType::Freeform => { plan.push_spec( @@ -285,10 +289,11 @@ pub fn build_tool_registry_plan( plan.register_handler("apply_patch", ToolHandlerKind::ApplyPatch); } - if config - .experimental_supported_tools - .iter() - .any(|tool| tool == "list_dir") + if config.has_environment + && config + .experimental_supported_tools + .iter() + .any(|tool| tool == "list_dir") { plan.push_spec( create_list_dir_tool(), @@ -331,14 +336,16 @@ pub fn build_tool_registry_plan( ); } - plan.push_spec( - create_view_image_tool(ViewImageToolOptions { - can_request_original_image_detail: config.can_request_original_image_detail, - }), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); - plan.register_handler("view_image", ToolHandlerKind::ViewImage); + if config.has_environment { + plan.push_spec( + create_view_image_tool(ViewImageToolOptions { + can_request_original_image_detail: config.can_request_original_image_detail, + }), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + plan.register_handler("view_image", ToolHandlerKind::ViewImage); + } if config.collab_tools { if config.multi_agent_v2 { diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index fd7ab10073..c73302d1be 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -453,6 +453,42 @@ fn view_image_tool_includes_detail_with_original_detail_feature() { assert!(description.contains("omit this field for default resized behavior")); } +#[test] +fn disabled_environment_omits_environment_backed_tools() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::JsRepl); + let available_models = Vec::new(); + let mut tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_has_environment(/*has_environment*/ false); + tools_config + .experimental_supported_tools + .push("list_dir".to_string()); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*app_tools*/ None, + &[], + ); + + assert_lacks_tool_name(&tools, "exec_command"); + assert_lacks_tool_name(&tools, "write_stdin"); + assert_lacks_tool_name(&tools, "js_repl"); + assert_lacks_tool_name(&tools, "js_repl_reset"); + assert_lacks_tool_name(&tools, "apply_patch"); + assert_lacks_tool_name(&tools, "list_dir"); + assert_lacks_tool_name(&tools, VIEW_IMAGE_TOOL_NAME); +} + #[test] fn test_build_specs_agent_job_worker_tools_enabled() { let model_info = model_info(); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..cf42e85160 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -64,6 +64,7 @@ use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListParams; @@ -1871,8 +1872,8 @@ impl App { Ok(()) } - /// Spawn a background task that fetches the full MCP server inventory from the - /// app-server via paginated RPCs, then delivers the result back through + /// Spawn a background task that fetches MCP server status from the app-server + /// via paginated RPCs, then delivers the result back through /// `AppEvent::McpInventoryLoaded`. /// /// The spawned task is fire-and-forget: no `JoinHandle` is stored, so a stale @@ -2125,7 +2126,9 @@ impl App { self.chat_widget .add_to_history(history_cell::new_mcp_tools_output_from_statuses( - &config, &statuses, + &config, + &statuses, + McpServerStatusDetail::ToolsAndAuthOnly, )); } @@ -6002,8 +6005,9 @@ impl App { } } -/// Collect every MCP server status from the app-server by walking the paginated -/// `mcpServerStatus/list` RPC until no `next_cursor` is returned. +/// Collect every MCP server status needed for `/mcp` from the app-server by +/// walking the paginated `mcpServerStatus/list` RPC until no `next_cursor` is +/// returned. /// /// All pages are eagerly gathered into a single `Vec` so the caller can render /// the inventory atomically. Each page requests up to 100 entries. @@ -6021,6 +6025,7 @@ async fn fetch_all_mcp_server_statuses( params: ListMcpServerStatusParams { cursor: cursor.clone(), limit: Some(100), + detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), }, }) .await @@ -9186,13 +9191,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9214,11 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10439,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10462,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..995af44b28 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..6bae217362 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d152e99f68 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 34c0df7b4b..786911f158 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5889,6 +5889,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5909,6 +5912,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..b5e9995d69 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..68a5659246 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..4f43b498d7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..8b1180cfa3 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..29b1b8c591 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..c42ec4fbac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c6fff59897..fdf7c5008b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -656,6 +672,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..00be80d4e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 86d9949c58..bc72304974 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -337,6 +337,7 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { dangerously_allow_all_unix_sockets, domains, managed_allowed_domains_only, + danger_full_access_denylist_only, unix_sockets, allow_local_binding, } = network; @@ -374,6 +375,11 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { "managed_allowed_domains_only={managed_allowed_domains_only}" )); } + if let Some(danger_full_access_denylist_only) = danger_full_access_denylist_only { + parts.push(format!( + "danger_full_access_denylist_only={danger_full_access_denylist_only}" + )); + } if let Some(unix_sockets) = unix_sockets { parts.push(format!( "unix_sockets={}", @@ -557,6 +563,7 @@ mod tests { NetworkDomainPermissionToml::Allow, )]), }), + danger_full_access_denylist_only: Some(true), ..Default::default() }, RequirementSource::CloudRequirements, @@ -621,7 +628,7 @@ mod tests { assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); assert!(rendered.contains( - "experimental_network: enabled=true, domains={example.com=allow} (source: cloud requirements)" + "experimental_network: enabled=true, domains={example.com=allow}, danger_full_access_denylist_only=true (source: cloud requirements)" )); assert!(!rendered.contains(" - rules:")); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index cf65f918e5..6821adb5a8 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -40,6 +40,7 @@ use crate::wrapping::adaptive_wrap_line; use crate::wrapping::adaptive_wrap_lines; use base64::Engine; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::McpServerStatusDetail; use codex_config::types::McpServerTransportConfig; use codex_core::config::Config; #[cfg(test)] @@ -1979,10 +1980,12 @@ pub(crate) fn new_mcp_tools_output( /// transport details such as command, URL, cwd, and environment display. /// /// This mirrors the layout of [`new_mcp_tools_output`] but sources data from -/// the paginated RPC response rather than the in-process `McpManager`. +/// the paginated RPC response rather than the in-process `McpManager`. The +/// `detail` flag controls whether resources and resource templates are rendered. pub(crate) fn new_mcp_tools_output_from_statuses( config: &Config, statuses: &[McpServerStatus], + detail: McpServerStatusDetail, ) -> PlainHistoryCell { let mut lines: Vec> = vec![ "/mcp".magenta().into(), @@ -2094,48 +2097,50 @@ pub(crate) fn new_mcp_tools_output_from_statuses( lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); } - let server_resources = status - .map(|status| status.resources.clone()) - .unwrap_or_default(); - if server_resources.is_empty() { - lines.push(" • Resources: (none)".into()); - } else { - let mut spans: Vec> = vec![" • Resources: ".into()]; + if matches!(detail, McpServerStatusDetail::Full) { + let server_resources = status + .map(|status| status.resources.clone()) + .unwrap_or_default(); + if server_resources.is_empty() { + lines.push(" • Resources: (none)".into()); + } else { + let mut spans: Vec> = vec![" • Resources: ".into()]; - for (idx, resource) in server_resources.iter().enumerate() { - if idx > 0 { - spans.push(", ".into()); + for (idx, resource) in server_resources.iter().enumerate() { + if idx > 0 { + spans.push(", ".into()); + } + + let label = resource.title.as_ref().unwrap_or(&resource.name); + spans.push(label.clone().into()); + spans.push(" ".into()); + spans.push(format!("({})", resource.uri).dim()); } - let label = resource.title.as_ref().unwrap_or(&resource.name); - spans.push(label.clone().into()); - spans.push(" ".into()); - spans.push(format!("({})", resource.uri).dim()); + lines.push(spans.into()); } - lines.push(spans.into()); - } + let server_templates = status + .map(|status| status.resource_templates.clone()) + .unwrap_or_default(); + if server_templates.is_empty() { + lines.push(" • Resource templates: (none)".into()); + } else { + let mut spans: Vec> = vec![" • Resource templates: ".into()]; - let server_templates = status - .map(|status| status.resource_templates.clone()) - .unwrap_or_default(); - if server_templates.is_empty() { - lines.push(" • Resource templates: (none)".into()); - } else { - let mut spans: Vec> = vec![" • Resource templates: ".into()]; + for (idx, template) in server_templates.iter().enumerate() { + if idx > 0 { + spans.push(", ".into()); + } - for (idx, template) in server_templates.iter().enumerate() { - if idx > 0 { - spans.push(", ".into()); + let label = template.title.as_ref().unwrap_or(&template.name); + spans.push(label.clone().into()); + spans.push(" ".into()); + spans.push(format!("({})", template.uri_template).dim()); } - let label = template.title.as_ref().unwrap_or(&template.name); - spans.push(label.clone().into()); - spans.push(" ".into()); - spans.push(format!("({})", template.uri_template).dim()); + lines.push(spans.into()); } - - lines.push(spans.into()); } lines.push(Line::from("")); @@ -3340,7 +3345,11 @@ mod tests { auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, }]; - let cell = new_mcp_tools_output_from_statuses(&config, &statuses); + let cell = new_mcp_tools_output_from_statuses( + &config, + &statuses, + McpServerStatusDetail::ToolsAndAuthOnly, + ); let rendered = render_lines(&cell.display_lines(/*width*/ 120)).join("\n"); insta::assert_snapshot!(rendered); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 991c91c550..4e90c909e1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1697,9 +1697,9 @@ mod tests { use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; + use codex_config::config_toml::ProjectConfig; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; - use codex_core::config::ProjectConfig; use codex_features::Feature; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::RolloutItem; diff --git a/codex-rs/tui/src/local_chatgpt_auth.rs b/codex-rs/tui/src/local_chatgpt_auth.rs index cb8a3a4aae..e888c0387c 100644 --- a/codex-rs/tui/src/local_chatgpt_auth.rs +++ b/codex-rs/tui/src/local_chatgpt_auth.rs @@ -1,7 +1,9 @@ +#![cfg(test)] + use std::path::Path; use codex_app_server_protocol::AuthMode; -use codex_login::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::load_auth_dot_json; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index a5915618ca..f991d028cd 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -8,8 +8,6 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginAccountResponse; -use codex_login::AuthCredentialsStoreMode; -use codex_login::DeviceCode; use codex_login::read_openai_api_key_from_env; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -33,6 +31,7 @@ use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use codex_protocol::config_types::ForcedLoginMethod; +use std::cell::Cell; use std::sync::Arc; use std::sync::RwLock; use uuid::Uuid; @@ -77,8 +76,6 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { } } } -use std::path::PathBuf; -use tokio::sync::Notify; use super::onboarding_screen::StepState; @@ -108,6 +105,20 @@ fn onboarding_request_id() -> codex_app_server_protocol::RequestId { codex_app_server_protocol::RequestId::String(Uuid::new_v4().to_string()) } +pub(super) async fn cancel_login_attempt( + request_handle: &AppServerRequestHandle, + login_id: String, +) { + let _ = request_handle + .request_typed::( + ClientRequest::CancelLoginAccount { + request_id: onboarding_request_id(), + params: CancelLoginAccountParams { login_id }, + }, + ) + .await; +} + #[derive(Clone, Default)] pub(crate) struct ApiKeyInputState { value: String, @@ -123,8 +134,49 @@ pub(crate) struct ContinueInBrowserState { #[derive(Clone)] pub(crate) struct ContinueWithDeviceCodeState { - device_code: Option, - cancel: Option>, + request_id: String, + login_id: Option, + verification_url: Option, + user_code: Option, +} + +impl ContinueWithDeviceCodeState { + pub(crate) fn pending(request_id: String) -> Self { + Self { + request_id, + login_id: None, + verification_url: None, + user_code: None, + } + } + + pub(crate) fn ready( + request_id: String, + login_id: String, + verification_url: String, + user_code: String, + ) -> Self { + Self { + request_id, + login_id: Some(login_id), + verification_url: Some(verification_url), + user_code: Some(user_code), + } + } + + pub(crate) fn login_id(&self) -> Option<&str> { + self.login_id.as_deref() + } + + pub(crate) fn is_showing_copyable_auth(&self) -> bool { + self.verification_url + .as_deref() + .is_some_and(|url| !url.is_empty()) + && self + .user_code + .as_deref() + .is_some_and(|user_code| !user_code.is_empty()) + } } impl KeyboardHandler for AuthModeWidget { @@ -181,16 +233,25 @@ pub(crate) struct AuthModeWidget { pub highlighted_mode: SignInOption, pub error: Arc>>, pub sign_in_state: Arc>, - pub codex_home: PathBuf, - pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub login_status: LoginStatus, pub app_server_request_handle: AppServerRequestHandle, - pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub animations_enabled: bool, + pub animations_suppressed: Cell, } impl AuthModeWidget { + pub(crate) fn set_animations_suppressed(&self, suppressed: bool) { + self.animations_suppressed.set(suppressed); + } + + pub(crate) fn should_suppress_animations(&self) -> bool { + matches!( + &*self.sign_in_state.read().unwrap(), + SignInState::ChatGptContinueInBrowser(_) | SignInState::ChatGptDeviceCode(_) + ) + } + pub(crate) fn cancel_active_attempt(&self) { let mut sign_in_state = self.sign_in_state.write().unwrap(); match &*sign_in_state { @@ -198,19 +259,15 @@ impl AuthModeWidget { let request_handle = self.app_server_request_handle.clone(); let login_id = state.login_id.clone(); tokio::spawn(async move { - let _ = request_handle - .request_typed::( - ClientRequest::CancelLoginAccount { - request_id: onboarding_request_id(), - params: CancelLoginAccountParams { login_id }, - }, - ) - .await; + cancel_login_attempt(&request_handle, login_id).await; }); } SignInState::ChatGptDeviceCode(state) => { - if let Some(cancel) = &state.cancel { - cancel.notify_one(); + if let Some(login_id) = state.login_id().map(str::to_owned) { + let request_handle = self.app_server_request_handle.clone(); + tokio::spawn(async move { + cancel_login_attempt(&request_handle, login_id).await; + }); } } _ => return, @@ -415,7 +472,7 @@ impl AuthModeWidget { fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) { let mut spans = vec![" ".into()]; - if self.animations_enabled { + if self.animations_enabled && !self.animations_suppressed.get() { // Schedule a follow-up frame to keep the shimmer animation going. self.request_frame .schedule_frame_in(std::time::Duration::from_millis(100)); @@ -814,6 +871,9 @@ impl AuthModeWidget { let is_matching_login = matches!( &*guard, SignInState::ChatGptContinueInBrowser(state) if state.login_id == login_id + ) || matches!( + &*guard, + SignInState::ChatGptDeviceCode(state) if state.login_id() == Some(login_id.as_str()) ); drop(guard); if !is_matching_login { @@ -900,6 +960,7 @@ mod tests { use codex_app_server_client::InProcessClientStartArgs; use codex_arg0::Arg0DispatchPaths; use codex_cloud_requirements::cloud_requirements_loader_for_storage; + use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::ConfigBuilder; use codex_protocol::protocol::SessionSource; @@ -943,13 +1004,11 @@ mod tests { highlighted_mode: SignInOption::ChatGpt, error: Arc::new(RwLock::new(None)), sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), - codex_home: codex_home_path.clone(), - cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, login_status: LoginStatus::NotAuthenticated, app_server_request_handle: AppServerRequestHandle::InProcess(client.request_handle()), - forced_chatgpt_workspace_id: None, forced_login_method: Some(ForcedLoginMethod::Chatgpt), animations_enabled: true, + animations_suppressed: std::cell::Cell::new(false), }; (widget, codex_home) } @@ -1023,13 +1082,14 @@ mod tests { #[tokio::test] async fn cancel_active_attempt_notifies_device_code_login() { let (widget, _tmp) = widget_forced_chatgpt().await; - let cancel = Arc::new(Notify::new()); *widget.error.write().unwrap() = Some("still logging in".to_string()); *widget.sign_in_state.write().unwrap() = - SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { - device_code: None, - cancel: Some(cancel.clone()), - }); + SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::ready( + "request-1".to_string(), + "login-1".to_string(), + "https://chatgpt.com/device".to_string(), + "ABCD-EFGH".to_string(), + )); widget.cancel_active_attempt(); @@ -1038,11 +1098,6 @@ mod tests { &*widget.sign_in_state.read().unwrap(), SignInState::PickMode )); - assert!( - tokio::time::timeout(std::time::Duration::from_millis(50), cancel.notified()) - .await - .is_ok() - ); } /// Collects all buffer cell symbols that contain the OSC 8 open sequence @@ -1085,6 +1140,55 @@ mod tests { assert_eq!(found, url, "OSC 8 hyperlink should cover the full URL"); } + #[test] + fn auth_widget_suppresses_animations_when_device_code_is_visible() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt()); + *widget.sign_in_state.write().unwrap() = + SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::ready( + "request-1".to_string(), + "login-1".to_string(), + "https://chatgpt.com/device".to_string(), + "ABCD-EFGH".to_string(), + )); + + assert_eq!(widget.should_suppress_animations(), true); + } + + #[test] + fn auth_widget_suppresses_animations_while_requesting_device_code() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt()); + *widget.sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode( + ContinueWithDeviceCodeState::pending("request-1".to_string()), + ); + + assert_eq!(widget.should_suppress_animations(), true); + } + + #[tokio::test] + async fn device_code_login_completion_advances_to_success_message() { + let (mut widget, _tmp) = widget_forced_chatgpt().await; + *widget.sign_in_state.write().unwrap() = + SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::ready( + "request-1".to_string(), + "login-1".to_string(), + "https://chatgpt.com/device".to_string(), + "ABCD-EFGH".to_string(), + )); + + widget.on_account_login_completed(AccountLoginCompletedNotification { + login_id: Some("login-1".to_string()), + success: true, + error: None, + }); + + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::ChatGptSuccessMessage + )); + } + #[test] fn mark_url_hyperlink_wraps_cyan_underlined_cells() { let url = "https://example.com"; diff --git a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs index 97709e9493..a3e82e6cd8 100644 --- a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs +++ b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs @@ -1,12 +1,6 @@ -#![allow(dead_code)] - use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginAccountResponse; -use codex_login::CLIENT_ID; -use codex_login::ServerOptions; -use codex_login::complete_device_code_login; -use codex_login::request_device_code; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -14,109 +8,74 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; +use uuid::Uuid; -use std::sync::Arc; -use std::sync::RwLock; -use tokio::sync::Notify; - -use crate::local_chatgpt_auth::LocalChatgptAuth; -use crate::local_chatgpt_auth::load_local_chatgpt_auth; use crate::shimmer::shimmer_spans; -use crate::tui::FrameRequester; use super::AuthModeWidget; -use super::ContinueInBrowserState; use super::ContinueWithDeviceCodeState; use super::SignInState; +use super::cancel_login_attempt; use super::mark_url_hyperlink; -use super::maybe_open_auth_url_in_browser; use super::onboarding_request_id; pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget) { - let mut opts = ServerOptions::new( - widget.codex_home.clone(), - CLIENT_ID.to_string(), - widget.forced_chatgpt_workspace_id.clone(), - widget.cli_auth_credentials_store_mode, - ); - opts.open_browser = false; + let request_id = Uuid::new_v4().to_string(); + *widget.sign_in_state.write().unwrap() = + SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::pending(request_id.clone())); + widget.request_frame.schedule_frame(); + let request_handle = widget.app_server_request_handle.clone(); let sign_in_state = widget.sign_in_state.clone(); let request_frame = widget.request_frame.clone(); let error = widget.error.clone(); - let request_handle = widget.app_server_request_handle.clone(); - let codex_home = widget.codex_home.clone(); - let cli_auth_credentials_store_mode = widget.cli_auth_credentials_store_mode; - let forced_chatgpt_workspace_id = widget.forced_chatgpt_workspace_id.clone(); - let cancel = begin_device_code_attempt(&sign_in_state, &request_frame); - tokio::spawn(async move { - let device_code = match request_device_code(&opts).await { - Ok(device_code) => device_code, - Err(err) => { - if err.kind() == std::io::ErrorKind::NotFound { - fallback_to_browser_login( - request_handle, - sign_in_state, - request_frame, - error, - cancel, - ) - .await; + match request_handle + .request_typed::(ClientRequest::LoginAccount { + request_id: onboarding_request_id(), + params: LoginAccountParams::ChatgptDeviceCode, + }) + .await + { + Ok(LoginAccountResponse::ChatgptDeviceCode { + login_id, + verification_url, + user_code, + }) => { + let updated = set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &request_id, + ContinueWithDeviceCodeState::ready( + request_id.clone(), + login_id.clone(), + verification_url, + user_code, + ), + ); + if updated { + *error.write().unwrap() = None; } else { - set_device_code_error_for_active_attempt( - &sign_in_state, - &request_frame, - &error, - &cancel, - err.to_string(), - ); + cancel_login_attempt(&request_handle, login_id).await; } - return; } - }; - - if !set_device_code_state_for_active_attempt( - &sign_in_state, - &request_frame, - &cancel, - SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { - device_code: Some(device_code.clone()), - cancel: Some(cancel.clone()), - }), - ) { - return; - } - - tokio::select! { - _ = cancel.notified() => {} - result = complete_device_code_login(opts, device_code) => { - match result { - Ok(()) => { - let local_auth = load_local_chatgpt_auth( - &codex_home, - cli_auth_credentials_store_mode, - forced_chatgpt_workspace_id.as_deref(), - ); - handle_chatgpt_auth_tokens_login_result_for_active_attempt( - request_handle, - sign_in_state, - request_frame, - error, - cancel, - local_auth, - ).await; - } - Err(err) => { - set_device_code_error_for_active_attempt( - &sign_in_state, - &request_frame, - &error, - &cancel, - err.to_string(), - ); - } - } + Ok(other) => { + let _updated = set_device_code_error_for_active_attempt( + &sign_in_state, + &request_frame, + &error, + &request_id, + format!("Unexpected account/login/start response: {other:?}"), + ); + } + Err(err) => { + let _updated = set_device_code_error_for_active_attempt( + &sign_in_state, + &request_frame, + &error, + &request_id, + err.to_string(), + ); } } }); @@ -128,15 +87,14 @@ pub(super) fn render_device_code_login( buf: &mut Buffer, state: &ContinueWithDeviceCodeState, ) { - let banner = if state.device_code.is_some() { + let banner = if state.is_showing_copyable_auth() { "Finish signing in via your browser" } else { "Preparing device code login" }; let mut spans = vec![" ".into()]; - if widget.animations_enabled { - // Schedule a follow-up frame to keep the shimmer animation going. + if widget.animations_enabled && !widget.animations_suppressed.get() { widget .request_frame .schedule_frame_in(std::time::Duration::from_millis(100)); @@ -147,13 +105,14 @@ pub(super) fn render_device_code_login( let mut lines = vec![spans.into(), "".into()]; - // Capture the verification URL for OSC 8 hyperlink marking after render. - let verification_url = if let Some(device_code) = &state.device_code { + let verification_url = if let (Some(verification_url), Some(user_code)) = + (&state.verification_url, &state.user_code) + { lines.push(" 1. Open this link in your browser and sign in".into()); lines.push("".into()); lines.push(Line::from(vec![ " ".into(), - device_code.verification_url.as_str().cyan().underlined(), + verification_url.as_str().cyan().underlined(), ])); lines.push("".into()); lines.push( @@ -162,7 +121,7 @@ pub(super) fn render_device_code_login( lines.push("".into()); lines.push(Line::from(vec![ " ".into(), - device_code.user_code.as_str().cyan().bold(), + user_code.as_str().cyan().bold(), ])); lines.push("".into()); lines.push( @@ -171,7 +130,7 @@ pub(super) fn render_device_code_login( .into(), ); lines.push("".into()); - Some(device_code.verification_url.clone()) + Some(verification_url.clone()) } else { lines.push(" Requesting a one-time code...".dim().into()); lines.push("".into()); @@ -183,286 +142,139 @@ pub(super) fn render_device_code_login( .wrap(Wrap { trim: false }) .render(area, buf); - // Wrap cyan+underlined URL cells with OSC 8 so the terminal treats - // the entire region as a single clickable hyperlink. if let Some(url) = &verification_url { mark_url_hyperlink(buf, area, url); } } -fn device_code_attempt_matches(state: &SignInState, cancel: &Arc) -> bool { +fn device_code_attempt_matches(state: &SignInState, request_id: &str) -> bool { matches!( state, - SignInState::ChatGptDeviceCode(state) - if state - .cancel - .as_ref() - .is_some_and(|existing| Arc::ptr_eq(existing, cancel)) + SignInState::ChatGptDeviceCode(state) if state.request_id == request_id ) } -fn begin_device_code_attempt( - sign_in_state: &Arc>, - request_frame: &FrameRequester, -) -> Arc { - let cancel = Arc::new(Notify::new()); - *sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { - device_code: None, - cancel: Some(cancel.clone()), - }); - request_frame.schedule_frame(); - cancel -} - fn set_device_code_state_for_active_attempt( - sign_in_state: &Arc>, - request_frame: &FrameRequester, - cancel: &Arc, - next_state: SignInState, + sign_in_state: &std::sync::Arc>, + request_frame: &crate::tui::FrameRequester, + request_id: &str, + next_state: ContinueWithDeviceCodeState, ) -> bool { let mut guard = sign_in_state.write().unwrap(); - if !device_code_attempt_matches(&guard, cancel) { + if !device_code_attempt_matches(&guard, request_id) { return false; } - *guard = next_state; - drop(guard); - request_frame.schedule_frame(); - true -} - -fn set_device_code_success_message_for_active_attempt( - sign_in_state: &Arc>, - request_frame: &FrameRequester, - cancel: &Arc, -) -> bool { - let mut guard = sign_in_state.write().unwrap(); - if !device_code_attempt_matches(&guard, cancel) { - return false; - } - - *guard = SignInState::ChatGptSuccessMessage; + *guard = SignInState::ChatGptDeviceCode(next_state); drop(guard); request_frame.schedule_frame(); true } fn set_device_code_error_for_active_attempt( - sign_in_state: &Arc>, - request_frame: &FrameRequester, - error: &Arc>>, - cancel: &Arc, + sign_in_state: &std::sync::Arc>, + request_frame: &crate::tui::FrameRequester, + error: &std::sync::Arc>>, + request_id: &str, message: String, ) -> bool { - if !set_device_code_state_for_active_attempt( - sign_in_state, - request_frame, - cancel, - SignInState::PickMode, - ) { + let mut guard = sign_in_state.write().unwrap(); + if !device_code_attempt_matches(&guard, request_id) { return false; } + + *guard = SignInState::PickMode; + drop(guard); *error.write().unwrap() = Some(message); request_frame.schedule_frame(); true } -async fn fallback_to_browser_login( - request_handle: codex_app_server_client::AppServerRequestHandle, - sign_in_state: Arc>, - request_frame: FrameRequester, - error: Arc>>, - cancel: Arc, -) { - let should_fallback = { - let guard = sign_in_state.read().unwrap(); - device_code_attempt_matches(&guard, &cancel) - }; - if !should_fallback { - return; - } - - match request_handle - .request_typed::(ClientRequest::LoginAccount { - request_id: onboarding_request_id(), - params: LoginAccountParams::Chatgpt, - }) - .await - { - Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => { - maybe_open_auth_url_in_browser(&request_handle, &auth_url); - *error.write().unwrap() = None; - let _updated = set_device_code_state_for_active_attempt( - &sign_in_state, - &request_frame, - &cancel, - SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { - login_id, - auth_url, - }), - ); - } - Ok(other) => { - set_device_code_error_for_active_attempt( - &sign_in_state, - &request_frame, - &error, - &cancel, - format!("Unexpected account/login/start response: {other:?}"), - ); - } - Err(err) => { - set_device_code_error_for_active_attempt( - &sign_in_state, - &request_frame, - &error, - &cancel, - err.to_string(), - ); - } - } -} - -async fn handle_chatgpt_auth_tokens_login_result_for_active_attempt( - request_handle: codex_app_server_client::AppServerRequestHandle, - sign_in_state: Arc>, - request_frame: FrameRequester, - error: Arc>>, - cancel: Arc, - local_auth: Result, -) { - let local_auth = match local_auth { - Ok(local_auth) => local_auth, - Err(err) => { - set_device_code_error_for_active_attempt( - &sign_in_state, - &request_frame, - &error, - &cancel, - err, - ); - return; - } - }; - - let result = request_handle - .request_typed::(ClientRequest::LoginAccount { - request_id: onboarding_request_id(), - params: LoginAccountParams::ChatgptAuthTokens { - access_token: local_auth.access_token, - chatgpt_account_id: local_auth.chatgpt_account_id, - chatgpt_plan_type: local_auth.chatgpt_plan_type, - }, - }) - .await; - apply_chatgpt_auth_tokens_login_response_for_active_attempt( - &sign_in_state, - &request_frame, - &error, - &cancel, - result.map_err(|err| err.to_string()), - ); -} - -fn apply_chatgpt_auth_tokens_login_response_for_active_attempt( - sign_in_state: &Arc>, - request_frame: &FrameRequester, - error: &Arc>>, - cancel: &Arc, - result: Result, -) { - match result { - Ok(LoginAccountResponse::ChatgptAuthTokens {}) => { - *error.write().unwrap() = None; - let _updated = set_device_code_success_message_for_active_attempt( - sign_in_state, - request_frame, - cancel, - ); - } - Ok(other) => { - set_device_code_error_for_active_attempt( - sign_in_state, - request_frame, - error, - cancel, - format!("Unexpected account/login/start response: {other:?}"), - ); - } - Err(err) => { - set_device_code_error_for_active_attempt( - sign_in_state, - request_frame, - error, - cancel, - err, - ); - } - } -} - #[cfg(test)] mod tests { use super::*; - use pretty_assertions::assert_eq; + use std::sync::Arc; + use std::sync::RwLock; - fn device_code_sign_in_state(cancel: Arc) -> Arc> { + fn pending_device_code_state(request_id: &str) -> Arc> { Arc::new(RwLock::new(SignInState::ChatGptDeviceCode( - ContinueWithDeviceCodeState { - device_code: None, - cancel: Some(cancel), - }, + ContinueWithDeviceCodeState::pending(request_id.to_string()), ))) } #[test] - fn device_code_attempt_matches_only_for_matching_cancel() { - let cancel = Arc::new(Notify::new()); - let state = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { - device_code: None, - cancel: Some(cancel.clone()), - }); - - assert_eq!(device_code_attempt_matches(&state, &cancel), true); - assert_eq!( - device_code_attempt_matches(&state, &Arc::new(Notify::new())), - false - ); - assert_eq!( - device_code_attempt_matches(&SignInState::PickMode, &cancel), - false - ); - } - - #[test] - fn begin_device_code_attempt_sets_state() { - let sign_in_state = Arc::new(RwLock::new(SignInState::PickMode)); - let request_frame = FrameRequester::test_dummy(); - - let cancel = begin_device_code_attempt(&sign_in_state, &request_frame); - let guard = sign_in_state.read().unwrap(); - - let state: &SignInState = &guard; - assert_eq!(device_code_attempt_matches(state, &cancel), true); - assert!(matches!( - state, - SignInState::ChatGptDeviceCode(state) if state.device_code.is_none() + fn device_code_attempt_matches_only_for_matching_request_id() { + let state = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::pending( + "request-1".to_string(), )); + + assert_eq!(device_code_attempt_matches(&state, "request-1"), true); + assert_eq!(device_code_attempt_matches(&state, "request-2"), false); + assert_eq!( + device_code_attempt_matches(&SignInState::PickMode, "request-1"), + false + ); } #[test] fn set_device_code_state_for_active_attempt_updates_only_when_active() { - let request_frame = FrameRequester::test_dummy(); - let cancel = Arc::new(Notify::new()); - let sign_in_state = device_code_sign_in_state(cancel.clone()); + let request_frame = crate::tui::FrameRequester::test_dummy(); + let sign_in_state = pending_device_code_state("request-1"); assert_eq!( set_device_code_state_for_active_attempt( &sign_in_state, &request_frame, - &cancel, - SignInState::PickMode, + "request-1", + ContinueWithDeviceCodeState::ready( + "request-1".to_string(), + "login-1".to_string(), + "https://example.com/device".to_string(), + "ABCD-EFGH".to_string(), + ), + ), + true + ); + assert!(matches!( + &*sign_in_state.read().unwrap(), + SignInState::ChatGptDeviceCode(state) if state.login_id() == Some("login-1") + )); + + let sign_in_state = pending_device_code_state("request-2"); + assert_eq!( + set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + "request-1", + ContinueWithDeviceCodeState::ready( + "request-1".to_string(), + "login-1".to_string(), + "https://example.com/device".to_string(), + "ABCD-EFGH".to_string(), + ), + ), + false + ); + assert!(matches!( + &*sign_in_state.read().unwrap(), + SignInState::ChatGptDeviceCode(state) if state.login_id.is_none() + )); + } + + #[test] + fn set_device_code_error_for_active_attempt_updates_only_when_active() { + let request_frame = crate::tui::FrameRequester::test_dummy(); + let error = Arc::new(RwLock::new(None)); + let sign_in_state = pending_device_code_state("request-1"); + + assert_eq!( + set_device_code_error_for_active_attempt( + &sign_in_state, + &request_frame, + &error, + "request-1", + "device code unavailable".to_string(), ), true ); @@ -470,79 +282,27 @@ mod tests { &*sign_in_state.read().unwrap(), SignInState::PickMode )); - - let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new())); assert_eq!( - set_device_code_state_for_active_attempt( - &sign_in_state, - &request_frame, - &cancel, - SignInState::PickMode, - ), - false + error.read().unwrap().as_deref(), + Some("device code unavailable") ); - assert!(matches!( - &*sign_in_state.read().unwrap(), - SignInState::ChatGptDeviceCode(_) - )); - } - #[test] - fn set_device_code_success_message_for_active_attempt_updates_only_when_active() { - let request_frame = FrameRequester::test_dummy(); - let cancel = Arc::new(Notify::new()); - let sign_in_state = device_code_sign_in_state(cancel.clone()); - assert_eq!( - set_device_code_success_message_for_active_attempt( - &sign_in_state, - &request_frame, - &cancel, - ), - true - ); - assert!(matches!( - &*sign_in_state.read().unwrap(), - SignInState::ChatGptSuccessMessage - )); - - let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new())); - assert_eq!( - set_device_code_success_message_for_active_attempt( - &sign_in_state, - &request_frame, - &cancel, - ), - false - ); - assert!(matches!( - &*sign_in_state.read().unwrap(), - SignInState::ChatGptDeviceCode(_) - )); - } - - #[test] - fn chatgpt_auth_tokens_success_sets_success_message_without_login_id() { - let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new())); - let request_frame = FrameRequester::test_dummy(); let error = Arc::new(RwLock::new(None)); - let cancel = match &*sign_in_state.read().unwrap() { - SignInState::ChatGptDeviceCode(state) => { - state.cancel.as_ref().expect("cancel handle").clone() - } - _ => panic!("expected device-code state"), - }; - - apply_chatgpt_auth_tokens_login_response_for_active_attempt( - &sign_in_state, - &request_frame, - &error, - &cancel, - Ok(LoginAccountResponse::ChatgptAuthTokens {}), + let sign_in_state = pending_device_code_state("request-2"); + assert_eq!( + set_device_code_error_for_active_attempt( + &sign_in_state, + &request_frame, + &error, + "request-1", + "device code unavailable".to_string(), + ), + false ); - assert!(matches!( &*sign_in_state.read().unwrap(), - SignInState::ChatGptSuccessMessage + SignInState::ChatGptDeviceCode(_) )); + assert_eq!(*error.read().unwrap(), None); } } diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index ec8e428947..be2e41bc8f 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -86,10 +86,8 @@ impl OnboardingScreen { config, } = args; let cwd = config.cwd.to_path_buf(); - let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); - let forced_login_method = config.forced_login_method; let codex_home = config.codex_home.clone(); - let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode; + let forced_login_method = config.forced_login_method; let mut steps: Vec = Vec::new(); steps.push(Step::Welcome(WelcomeWidget::new( !matches!(login_status, LoginStatus::NotAuthenticated), @@ -107,13 +105,11 @@ impl OnboardingScreen { highlighted_mode, error: Arc::new(RwLock::new(None)), sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), - codex_home: codex_home.clone(), - cli_auth_credentials_store_mode, login_status, app_server_request_handle, - forced_chatgpt_workspace_id, forced_login_method, animations_enabled: config.animations, + animations_suppressed: std::cell::Cell::new(false), })); } else { tracing::warn!("skipping onboarding login step without app-server request handle"); @@ -175,6 +171,15 @@ impl OnboardingScreen { out } + fn should_suppress_animations(&self) -> bool { + // Freeze the whole onboarding screen when auth is showing copyable login + // material so terminal selection is not interrupted by redraws. + self.current_steps().into_iter().any(|step| match step { + Step::Auth(widget) => widget.should_suppress_animations(), + Step::Welcome(_) | Step::TrustDirectory(_) => false, + }) + } + fn is_auth_in_progress(&self) -> bool { self.steps.iter().any(|step| { matches!(step, Step::Auth(_)) && matches!(step.get_step_state(), StepState::InProgress) @@ -323,6 +328,15 @@ impl KeyboardHandler for OnboardingScreen { impl WidgetRef for &OnboardingScreen { fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let suppress_animations = self.should_suppress_animations(); + for step in self.current_steps() { + match step { + Step::Welcome(widget) => widget.set_animations_suppressed(suppress_animations), + Step::Auth(widget) => widget.set_animations_suppressed(suppress_animations), + Step::TrustDirectory(_) => {} + } + } + Clear.render(area, buf); // Render steps top-to-bottom, measuring each step's height dynamically. let mut y = area.y; diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index fa668caabb..acb12abaab 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -27,6 +27,7 @@ pub(crate) struct WelcomeWidget { pub is_logged_in: bool, animation: AsciiAnimation, animations_enabled: bool, + animations_suppressed: Cell, layout_area: Cell>, } @@ -55,6 +56,7 @@ impl WelcomeWidget { is_logged_in, animation: AsciiAnimation::new(request_frame), animations_enabled, + animations_suppressed: Cell::new(false), layout_area: Cell::new(None), } } @@ -62,18 +64,23 @@ impl WelcomeWidget { pub(crate) fn update_layout_area(&self, area: Rect) { self.layout_area.set(Some(area)); } + + pub(crate) fn set_animations_suppressed(&self, suppressed: bool) { + self.animations_suppressed.set(suppressed); + } } impl WidgetRef for &WelcomeWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { Clear.render(area, buf); - if self.animations_enabled { + if self.animations_enabled && !self.animations_suppressed.get() { self.animation.schedule_next_frame(); } let layout_area = self.layout_area.get().unwrap_or(area); // Skip the animation entirely when the viewport is too small so we don't clip frames. let show_animation = self.animations_enabled + && !self.animations_suppressed.get() && layout_area.height >= MIN_ANIMATION_HEIGHT && layout_area.width >= MIN_ANIMATION_WIDTH; @@ -167,6 +174,7 @@ mod tests { /*variant_idx*/ 0, ), animations_enabled: true, + animations_suppressed: Cell::new(false), layout_area: Cell::new(None), }; diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap index ee84771027..709ce6d691 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap @@ -10,5 +10,3 @@ expression: rendered • Auth: Unsupported • Command: docs-server --stdio • Tools: lookup - • Resources: (none) - • Resource templates: (none)