mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
Merge remote-tracking branch 'origin/main' into codex/viyatb/0118-bwrap-regression
# Conflicts: # codex-rs/core/src/config/mod.rs
This commit is contained in:
Generated
+13
-4
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -151,6 +151,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dangerFullAccessDenylistOnly": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dangerouslyAllowAllUnixSockets": {
|
||||
"type": [
|
||||
"boolean",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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, };
|
||||
@@ -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";
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
@@ -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<ResourceContent>, };
|
||||
@@ -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";
|
||||
@@ -29,4 +29,4 @@ unixSockets: { [key in string]?: NetworkUnixSocketPermission } | null,
|
||||
/**
|
||||
* Legacy compatibility view derived from `unix_sockets`.
|
||||
*/
|
||||
allowUnixSockets: Array<string> | null, allowLocalBinding: boolean | null, };
|
||||
allowUnixSockets: Array<string> | null, allowLocalBinding: boolean | null, dangerFullAccessDenylistOnly: boolean | null, };
|
||||
|
||||
@@ -15,4 +15,16 @@ items: Array<ThreadItem>, 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, };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ThreadItem>,
|
||||
error: Option<TurnError>,
|
||||
status: TurnStatus,
|
||||
started_at: Option<i64>,
|
||||
completed_at: Option<i64>,
|
||||
duration_ms: Option<i64>,
|
||||
/// 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<i64>) -> Self {
|
||||
self.started_at = started_at;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PendingTurn> for Turn {
|
||||
@@ -1111,6 +1136,9 @@ impl From<PendingTurn> 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,
|
||||
})),
|
||||
];
|
||||
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
pub allow_local_binding: Option<bool>,
|
||||
pub danger_full_access_denylist_only: Option<bool>,
|
||||
}
|
||||
|
||||
#[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<u32>,
|
||||
/// Controls how much MCP inventory data to fetch for each server.
|
||||
/// Defaults to `Full` when omitted.
|
||||
#[ts(optional = nullable)]
|
||||
pub detail: Option<McpServerStatusDetail>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<McpResourceContent>,
|
||||
}
|
||||
|
||||
#[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<TurnError>,
|
||||
/// Unix timestamp (in seconds) when the turn started.
|
||||
#[ts(type = "number | null")]
|
||||
pub started_at: Option<i64>,
|
||||
/// Unix timestamp (in seconds) when the turn completed.
|
||||
#[ts(type = "number | null")]
|
||||
pub completed_at: Option<i64>,
|
||||
/// Duration between turn start and completion in milliseconds, if known.
|
||||
#[ts(type = "number | null")]
|
||||
pub duration_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[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": {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ fn transport_name(transport: AppServerTransport) -> &'static str {
|
||||
match transport {
|
||||
AppServerTransport::Stdio => "stdio",
|
||||
AppServerTransport::WebSocket { .. } => "websocket",
|
||||
AppServerTransport::Off => "off",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TurnError>,
|
||||
started_at: Option<i64>,
|
||||
completed_at: Option<i64>,
|
||||
duration_ms: Option<i64>,
|
||||
}
|
||||
|
||||
async fn emit_turn_completed_with_status(
|
||||
conversation_id: ThreadId,
|
||||
event_turn_id: String,
|
||||
status: TurnStatus,
|
||||
error: Option<TurnError>,
|
||||
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<Mutex<ThreadState>>,
|
||||
) {
|
||||
@@ -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<Mutex<ThreadState>>,
|
||||
) {
|
||||
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<OutgoingEnvelope>,
|
||||
) -> Result<OutgoingMessage> {
|
||||
@@ -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?;
|
||||
|
||||
@@ -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<CodexAuth>,
|
||||
) {
|
||||
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::<McpResourceReadResponse>(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(),
|
||||
},
|
||||
|
||||
@@ -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([(
|
||||
|
||||
@@ -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::<ProcessorCommand>(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,
|
||||
},
|
||||
})
|
||||
));
|
||||
|
||||
@@ -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::<String>();
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -193,7 +193,7 @@ pub(crate) struct MessageProcessorArgs {
|
||||
pub(crate) log_db: Option<LogDbLayer>,
|
||||
pub(crate) config_warnings: Vec<ConfigWarningNotification>,
|
||||
pub(crate) session_source: SessionSource,
|
||||
pub(crate) enable_codex_api_key_env: bool,
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<i64>,
|
||||
pub(crate) file_change_started: HashSet<String>,
|
||||
pub(crate) command_execution_started: HashSet<String>,
|
||||
pub(crate) last_error: Option<TurnError>,
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<SocketAddr>().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<TransportEvent>,
|
||||
writer: &mpsc::Sender<QueuedOutgoingMessage>,
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<u64>,
|
||||
status_tx: watch::Sender<PongStatus>,
|
||||
}
|
||||
|
||||
pub(crate) struct ClientTracker {
|
||||
clients: HashMap<(ClientId, StreamId), ClientState>,
|
||||
legacy_stream_ids: HashMap<ClientId, StreamId>,
|
||||
join_set: JoinSet<(ClientId, StreamId)>,
|
||||
server_event_tx: mpsc::Sender<QueuedServerEnvelope>,
|
||||
transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
shutdown_token: CancellationToken,
|
||||
}
|
||||
|
||||
impl ClientTracker {
|
||||
pub(crate) fn new(
|
||||
server_event_tx: mpsc::Sender<QueuedServerEnvelope>,
|
||||
transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
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::<QueuedOutgoingMessage>(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<QueuedServerEnvelope>,
|
||||
mut writer_rx: mpsc::Receiver<QueuedOutgoingMessage>,
|
||||
mut status_rx: watch::Receiver<PongStatus>,
|
||||
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<Vec<(ClientId, StreamId)>, 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RemoteControlEnrollment> {
|
||||
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 "<empty>".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("<invalid utf-8>").to_owned())
|
||||
.unwrap_or_else(|| "<none>".to_owned());
|
||||
let cf_ray_str = headers
|
||||
.get(CF_RAY_HEADER)
|
||||
.map(|value| value.to_str().unwrap_or("<invalid utf-8>").to_owned())
|
||||
.unwrap_or_else(|| "<none>".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<RemoteControlEnrollment> {
|
||||
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::<EnrollRemoteServerResponse>(&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> {
|
||||
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: <none>, cf-ray: <none>, 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");
|
||||
}
|
||||
}
|
||||
@@ -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<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
pub(crate) async fn start_remote_control(
|
||||
remote_control_url: String,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
shutdown_token: CancellationToken,
|
||||
app_server_client_name_rx: Option<oneshot::Receiver<String>>,
|
||||
) -> io::Result<JoinHandle<()>> {
|
||||
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<AuthManager>,
|
||||
) -> 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;
|
||||
@@ -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<StreamId>,
|
||||
/// 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<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[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<OutgoingMessage>,
|
||||
},
|
||||
#[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<Host<&str>>) -> 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<Host<&str>>) -> 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<RemoteControlTarget> {
|
||||
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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<TransportEvent>,
|
||||
stdio_handles: &mut Vec<JoinHandle<()>>,
|
||||
initialize_client_name_tx: oneshot::Sender<String>,
|
||||
) -> IoResult<()> {
|
||||
let connection_id = ConnectionId(0);
|
||||
let connection_id = next_connection_id();
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel::<QueuedOutgoingMessage>(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<String> {
|
||||
let message = serde_json::from_str::<JSONRPCMessage>(line).ok()?;
|
||||
let JSONRPCMessage::Request(JSONRPCRequest { method, params, .. }) = message else {
|
||||
return None;
|
||||
};
|
||||
if method != "initialize" {
|
||||
return None;
|
||||
}
|
||||
let params = serde_json::from_value::<InitializeParams>(params?).ok()?;
|
||||
Some(params.client_info.name)
|
||||
}
|
||||
|
||||
@@ -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<TransportEvent>,
|
||||
connection_counter: Arc<AtomicU64>,
|
||||
auth_policy: Arc<WebsocketAuthPolicy>,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<i64> {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<McpResourceReadResponse>(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<RoleServer>,
|
||||
) -> Result<ReadResourceResult, rmcp::ErrorData> {
|
||||
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,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<PaginatedRequestParams>,
|
||||
_context: RequestContext<rmcp::service::RoleServer>,
|
||||
) -> Result<ListToolsResult, rmcp::ErrorData> {
|
||||
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<PaginatedRequestParams>,
|
||||
_context: RequestContext<rmcp::service::RoleServer>,
|
||||
) -> Result<ListResourcesResult, rmcp::ErrorData> {
|
||||
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<PaginatedRequestParams>,
|
||||
_context: RequestContext<rmcp::service::RoleServer>,
|
||||
) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
|
||||
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<_>>(),
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<ThreadMetadataUpdateResponse>(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;
|
||||
|
||||
@@ -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(),
|
||||
}))?,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<AppInfo>> {
|
||||
if !apps_enabled(config).await {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -346,7 +346,7 @@ struct AppServerCommand {
|
||||
subcommand: Option<AppServerSubcommand>,
|
||||
|
||||
/// 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 =
|
||||
|
||||
@@ -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<HashMap<String, String>>,
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
resolved_scopes: &ResolvedMcpOAuthScopes,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)`"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<v8::Value>,
|
||||
) {
|
||||
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<v8::Value>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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<RuntimeEvent>,
|
||||
) -> Result<(std_mpsc::Sender<RuntimeCommand>, 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<RuntimeEvent>,
|
||||
pending_tool_calls: HashMap<String, v8::Global<v8::PromiseResolver>>,
|
||||
pending_timeouts: HashMap<u64, timers::ScheduledTimeout>,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
enabled_tools: Vec<EnabledToolMetadata>,
|
||||
next_tool_call_id: u64,
|
||||
next_timeout_id: u64,
|
||||
tool_call_id: String,
|
||||
runtime_command_tx: std_mpsc::Sender<RuntimeCommand>,
|
||||
exit_requested: bool,
|
||||
}
|
||||
|
||||
@@ -168,6 +180,7 @@ fn run_runtime(
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
command_rx: std_mpsc::Receiver<RuntimeCommand>,
|
||||
isolate_handle_tx: std_mpsc::SyncSender<v8::IsolateHandle>,
|
||||
runtime_command_tx: std_mpsc::Sender<RuntimeCommand>,
|
||||
) {
|
||||
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();
|
||||
|
||||
@@ -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<v8::Function>,
|
||||
}
|
||||
|
||||
pub(super) fn schedule_timeout(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
) -> Result<u64, String> {
|
||||
let callback = args.get(0);
|
||||
if !callback.is_function() {
|
||||
return Err("setTimeout expects a function callback".to_string());
|
||||
}
|
||||
let callback = v8::Local::<v8::Function>::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::<RuntimeState>()
|
||||
.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::<RuntimeState>() 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::<RuntimeState>()
|
||||
.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<Option<u64>, 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String, crate::mcp::auth::McpAuthStatusEntry>,
|
||||
) -> 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<String, crate::mcp::auth::McpAuthStatusEntry>,
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<bool>,
|
||||
/// In danger-full-access mode, allow all network access and enforce managed deny entries.
|
||||
pub danger_full_access_denylist_only: Option<bool>,
|
||||
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
|
||||
pub allow_local_binding: Option<bool>,
|
||||
}
|
||||
@@ -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<bool>,
|
||||
/// In danger-full-access mode, allow all network access and enforce managed deny entries.
|
||||
danger_full_access_denylist_only: Option<bool>,
|
||||
#[serde(default)]
|
||||
denied_domains: Option<Vec<String>>,
|
||||
unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
|
||||
@@ -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<bool>,
|
||||
/// In danger-full-access mode, allow all network access and enforce managed deny entries.
|
||||
pub danger_full_access_denylist_only: Option<bool>,
|
||||
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
|
||||
pub allow_local_binding: Option<bool>,
|
||||
}
|
||||
@@ -384,6 +392,7 @@ impl From<NetworkRequirementsToml> 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<NetworkRequirementsToml> 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 {
|
||||
|
||||
@@ -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<String>,
|
||||
/// Review model override used by the `/review` feature.
|
||||
pub review_model: Option<String>,
|
||||
|
||||
/// Provider to use from the model_providers map.
|
||||
pub model_provider: Option<String>,
|
||||
|
||||
/// Size of the context window for the model, in tokens.
|
||||
pub model_context_window: Option<i64>,
|
||||
|
||||
/// Token usage threshold triggering auto-compaction of conversation history.
|
||||
pub model_auto_compact_token_limit: Option<i64>,
|
||||
|
||||
/// Default approval policy for executing commands.
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
|
||||
/// 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<ApprovalsReviewer>,
|
||||
|
||||
#[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<bool>,
|
||||
|
||||
/// Sandbox mode to use.
|
||||
pub sandbox_mode: Option<SandboxMode>,
|
||||
|
||||
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
|
||||
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
|
||||
|
||||
/// Default named permissions profile to apply from the `[permissions]`
|
||||
/// table.
|
||||
pub default_permissions: Option<String>,
|
||||
|
||||
/// Named permissions profiles.
|
||||
#[serde(default)]
|
||||
pub permissions: Option<PermissionsToml>,
|
||||
|
||||
/// Optional external command to spawn for end-user notifications.
|
||||
#[serde(default)]
|
||||
pub notify: Option<Vec<String>>,
|
||||
|
||||
/// System instructions.
|
||||
pub instructions: Option<String>,
|
||||
|
||||
/// Developer instructions inserted as a `developer` role message.
|
||||
#[serde(default)]
|
||||
pub developer_instructions: Option<String>,
|
||||
|
||||
/// Whether to inject the `<permissions instructions>` developer block.
|
||||
pub include_permissions_instructions: Option<bool>,
|
||||
|
||||
/// Whether to inject the `<apps_instructions>` developer block.
|
||||
pub include_apps_instructions: Option<bool>,
|
||||
|
||||
/// Whether to inject the `<environment_context>` user block.
|
||||
pub include_environment_context: Option<bool>,
|
||||
|
||||
/// 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<AbsolutePathBuf>,
|
||||
|
||||
/// Compact prompt used for history compaction.
|
||||
pub compact_prompt: Option<String>,
|
||||
|
||||
/// Optional commit attribution text for commit message co-author trailers.
|
||||
///
|
||||
/// Set to an empty string to disable automatic commit attribution.
|
||||
pub commit_attribution: Option<String>,
|
||||
|
||||
/// When set, restricts ChatGPT login to a specific workspace identifier.
|
||||
#[serde(default)]
|
||||
pub forced_chatgpt_workspace_id: Option<String>,
|
||||
|
||||
/// When set, restricts the login mechanism users may use.
|
||||
#[serde(default)]
|
||||
pub forced_login_method: Option<ForcedLoginMethod>,
|
||||
|
||||
/// 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<AuthCredentialsStoreMode>,
|
||||
|
||||
/// 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<String, McpServerConfig>,
|
||||
|
||||
/// 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<OAuthCredentialsStoreMode>,
|
||||
|
||||
/// 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<u16>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<String, ModelProviderInfo>,
|
||||
|
||||
/// Maximum number of bytes to include from an AGENTS.md project doc file.
|
||||
pub project_doc_max_bytes: Option<usize>,
|
||||
|
||||
/// Ordered list of fallback filenames to look for when AGENTS.md is missing.
|
||||
pub project_doc_fallback_filenames: Option<Vec<String>>,
|
||||
|
||||
/// Token budget applied when storing tool/function outputs in the context manager.
|
||||
pub tool_output_token_limit: Option<usize>,
|
||||
|
||||
/// Maximum poll window for background terminal output (`write_stdin`), in milliseconds.
|
||||
/// Default: `300000` (5 minutes).
|
||||
pub background_terminal_max_timeout: Option<u64>,
|
||||
|
||||
/// Optional absolute path to the Node runtime used by `js_repl`.
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Ordered list of directories to search for Node modules in `js_repl`.
|
||||
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
|
||||
|
||||
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
|
||||
pub zsh_path: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Profile to use from the `profiles` map.
|
||||
pub profile: Option<String>,
|
||||
|
||||
/// Named profiles to facilitate switching between different configurations.
|
||||
#[serde(default)]
|
||||
pub profiles: HashMap<String, ConfigProfile>,
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
#[serde(default)]
|
||||
pub history: Option<History>,
|
||||
|
||||
/// Directory where Codex stores the SQLite state DB.
|
||||
/// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`.
|
||||
pub sqlite_home: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Directory where Codex writes log files, for example `codex-tui.log`.
|
||||
/// Defaults to `$CODEX_HOME/log`.
|
||||
pub log_dir: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Optional URI-based file opener. If set, citations to files in the model
|
||||
/// output will be hyperlinked using the specified URI scheme.
|
||||
pub file_opener: Option<UriBasedFileOpener>,
|
||||
|
||||
/// Collection of settings that are specific to the TUI.
|
||||
pub tui: Option<Tui>,
|
||||
|
||||
/// When set to `true`, `AgentReasoning` events will be hidden from the
|
||||
/// UI/output. Defaults to `false`.
|
||||
pub hide_agent_reasoning: Option<bool>,
|
||||
|
||||
/// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output.
|
||||
/// Defaults to `false`.
|
||||
pub show_raw_agent_reasoning: Option<bool>,
|
||||
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
pub plan_mode_reasoning_effort: Option<ReasoningEffort>,
|
||||
pub model_reasoning_summary: Option<ReasoningSummary>,
|
||||
/// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).
|
||||
pub model_verbosity: Option<Verbosity>,
|
||||
|
||||
/// Override to force-enable reasoning summaries for the configured model.
|
||||
pub model_supports_reasoning_summaries: Option<bool>,
|
||||
|
||||
/// 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<AbsolutePathBuf>,
|
||||
|
||||
/// Optionally specify a personality for the model
|
||||
pub personality: Option<Personality>,
|
||||
|
||||
/// Optional explicit service tier preference for new turns (`fast` or `flex`).
|
||||
pub service_tier: Option<ServiceTier>,
|
||||
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
|
||||
/// Base URL override for the built-in `openai` model provider.
|
||||
pub openai_base_url: Option<String>,
|
||||
|
||||
/// Machine-local realtime audio device preferences used by realtime voice.
|
||||
#[serde(default)]
|
||||
pub audio: Option<RealtimeAudioToml>,
|
||||
|
||||
/// 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<String>,
|
||||
/// Experimental / do not use. Selects the realtime websocket model/snapshot
|
||||
/// used for the `Op::RealtimeConversation` connection.
|
||||
pub experimental_realtime_ws_model: Option<String>,
|
||||
/// Experimental / do not use. Realtime websocket session selection.
|
||||
/// `version` controls v1/v2 and `type` controls conversational/transcription.
|
||||
#[serde(default)]
|
||||
pub realtime: Option<RealtimeToml>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
pub projects: Option<HashMap<String, ProjectConfig>>,
|
||||
|
||||
/// Controls the web search tool mode: disabled, cached, or live.
|
||||
pub web_search: Option<WebSearchMode>,
|
||||
|
||||
/// Nested tools section for feature toggles
|
||||
pub tools: Option<ToolsToml>,
|
||||
|
||||
/// Additional discoverable tools that can be suggested for installation.
|
||||
pub tool_suggest: Option<ToolSuggestConfig>,
|
||||
|
||||
/// Agent-related settings (thread limits, etc.).
|
||||
pub agents: Option<AgentsToml>,
|
||||
|
||||
/// Memories subsystem settings.
|
||||
pub memories: Option<MemoriesToml>,
|
||||
|
||||
/// User-level skill config entries keyed by SKILL.md path.
|
||||
pub skills: Option<SkillsConfig>,
|
||||
|
||||
/// User-level plugin config entries keyed by plugin name.
|
||||
#[serde(default)]
|
||||
pub plugins: HashMap<String, PluginConfig>,
|
||||
|
||||
/// 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<FeaturesToml>,
|
||||
|
||||
/// Suppress warnings about unstable (under development) features.
|
||||
pub suppress_unstable_features_warning: Option<bool>,
|
||||
|
||||
/// Settings for ghost snapshots (used for undo).
|
||||
#[serde(default)]
|
||||
pub ghost_snapshot: Option<GhostSnapshotToml>,
|
||||
|
||||
/// 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<Vec<String>>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// When `false`, disables analytics across Codex product surfaces in this machine.
|
||||
/// Defaults to `true`.
|
||||
pub analytics: Option<AnalyticsConfigToml>,
|
||||
|
||||
/// When `false`, disables feedback collection across Codex product surfaces.
|
||||
/// Defaults to `true`.
|
||||
pub feedback: Option<FeedbackConfigToml>,
|
||||
|
||||
/// Settings for app-specific controls.
|
||||
#[serde(default)]
|
||||
pub apps: Option<AppsConfigToml>,
|
||||
|
||||
/// OTEL configuration.
|
||||
pub otel: Option<OtelConfigToml>,
|
||||
|
||||
/// Windows-specific configuration.
|
||||
#[serde(default)]
|
||||
pub windows: Option<WindowsToml>,
|
||||
|
||||
/// Tracks whether the Windows onboarding screen has been acknowledged.
|
||||
pub windows_wsl_setup_acknowledged: Option<bool>,
|
||||
|
||||
/// Collection of in-product notices (different from notifications)
|
||||
/// See [`crate::types::Notice`] for more details
|
||||
pub notice: Option<Notice>,
|
||||
|
||||
/// Legacy, now use features
|
||||
/// Deprecated: ignored. Use `model_instructions_file`.
|
||||
#[schemars(skip)]
|
||||
pub experimental_instructions_file: Option<AbsolutePathBuf>,
|
||||
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
|
||||
pub experimental_use_unified_exec_tool: Option<bool>,
|
||||
pub experimental_use_freeform_apply_patch: Option<bool>,
|
||||
/// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama".
|
||||
pub oss_provider: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ConfigToml> 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<TrustLevel>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub speaker: Option<String>,
|
||||
}
|
||||
|
||||
#[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<RealtimeWsVersion>,
|
||||
#[serde(rename = "type")]
|
||||
pub session_type: Option<RealtimeWsMode>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct RealtimeAudioToml {
|
||||
pub microphone: Option<String>,
|
||||
pub speaker: Option<String>,
|
||||
}
|
||||
|
||||
#[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<WebSearchToolConfig>,
|
||||
|
||||
/// Enable the `view_image` tool that lets the agent attach local images.
|
||||
#[serde(default)]
|
||||
pub view_image: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum WebSearchToolConfigInput {
|
||||
Enabled(bool),
|
||||
Config(WebSearchToolConfig),
|
||||
}
|
||||
|
||||
fn deserialize_optional_web_search_tool_config<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<WebSearchToolConfig>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<WebSearchToolConfigInput>::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<usize>,
|
||||
/// Maximum nesting depth allowed for spawned agent threads.
|
||||
/// Root sessions start at depth 0.
|
||||
#[schemars(range(min = 1))]
|
||||
pub max_depth: Option<i32>,
|
||||
/// Default maximum runtime in seconds for agent job workers.
|
||||
#[schemars(range(min = 1))]
|
||||
pub job_max_runtime_seconds: Option<u64>,
|
||||
|
||||
/// 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<String, AgentRoleToml>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
|
||||
/// Path to a role-specific config layer.
|
||||
/// Relative paths are resolved relative to the `config.toml` that defines them.
|
||||
pub config_file: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Candidate nicknames for agents spawned with this role.
|
||||
pub nickname_candidates: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl From<ToolsToml> 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<i64>,
|
||||
/// 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<i64>,
|
||||
/// Disable all ghost snapshot warning events.
|
||||
pub disable_warnings: Option<bool>,
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
/// Derive the effective sandbox policy from the configuration.
|
||||
pub fn derive_sandbox_policy(
|
||||
&self,
|
||||
sandbox_mode_override: Option<SandboxMode>,
|
||||
profile_sandbox_mode: Option<SandboxMode>,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
resolved_cwd: &Path,
|
||||
sandbox_policy_constraint: Option<&crate::Constrained<SandboxPolicy>>,
|
||||
) -> 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<ProjectConfig> {
|
||||
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<String>,
|
||||
) -> Result<ConfigProfile, std::io::Error> {
|
||||
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<String, ModelProviderInfo>,
|
||||
) -> Result<(), String> {
|
||||
let mut conflicts = model_providers
|
||||
.keys()
|
||||
.filter(|key| RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str()))
|
||||
.map(|key| format!("`{key}`"))
|
||||
.collect::<Vec<_>>();
|
||||
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<String, ModelProviderInfo>,
|
||||
) -> 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<HashMap<String, ModelProviderInfo>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let model_providers = HashMap::<String, ModelProviderInfo>::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}"
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user