mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
Merge 652bbe5b3f into sapling-pr-archive-bolinfest
This commit is contained in:
Generated
+3
-1
@@ -1469,6 +1469,7 @@ dependencies = [
|
||||
"codex-utils-cargo-bin",
|
||||
"inventory",
|
||||
"pretty_assertions",
|
||||
"rmcp",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1548,6 +1549,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
"which",
|
||||
"wiremock",
|
||||
"zip",
|
||||
]
|
||||
@@ -1985,7 +1987,6 @@ dependencies = [
|
||||
"sentry",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2212,6 +2213,7 @@ dependencies = [
|
||||
name = "codex-package-manager"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"fd-lock",
|
||||
"flate2",
|
||||
"pretty_assertions",
|
||||
"reqwest",
|
||||
|
||||
@@ -178,6 +178,7 @@ dirs = "6"
|
||||
dotenvy = "0.15.7"
|
||||
dunce = "1.0.4"
|
||||
encoding_rs = "0.8.35"
|
||||
fd-lock = "4.0.4"
|
||||
env-flags = "0.1.1"
|
||||
env_logger = "0.11.9"
|
||||
eventsource-stream = "0.2.3"
|
||||
|
||||
@@ -24,6 +24,12 @@ serde_with = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
strum_macros = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rmcp = { workspace = true, default-features = false, features = [
|
||||
"base64",
|
||||
"macros",
|
||||
"schemars",
|
||||
"server",
|
||||
] }
|
||||
ts-rs = { workspace = true }
|
||||
inventory = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -544,6 +544,56 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ElicitationRequest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"form"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requested_schema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requested_schema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"elicitation_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"url"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"elicitation_id",
|
||||
"message",
|
||||
"mode",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"EventMsg": {
|
||||
"description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.",
|
||||
"oneOf": [
|
||||
@@ -1963,8 +2013,8 @@
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
"request": {
|
||||
"$ref": "#/definitions/ElicitationRequest"
|
||||
},
|
||||
"server_name": {
|
||||
"type": "string"
|
||||
@@ -1979,7 +2029,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"message",
|
||||
"request",
|
||||
"server_name",
|
||||
"type"
|
||||
],
|
||||
@@ -7756,8 +7806,8 @@
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
"request": {
|
||||
"$ref": "#/definitions/ElicitationRequest"
|
||||
},
|
||||
"server_name": {
|
||||
"type": "string"
|
||||
@@ -7772,7 +7822,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"message",
|
||||
"request",
|
||||
"server_name",
|
||||
"type"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"form"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requestedSchema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requestedSchema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"elicitationId": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"url"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"elicitationId",
|
||||
"message",
|
||||
"mode",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"serverName": {
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"description": "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"serverName",
|
||||
"threadId"
|
||||
],
|
||||
"title": "McpServerElicitationRequestParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"McpServerElicitationAction": {
|
||||
"enum": [
|
||||
"accept",
|
||||
"decline",
|
||||
"cancel"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"action": {
|
||||
"$ref": "#/definitions/McpServerElicitationAction"
|
||||
},
|
||||
"content": {
|
||||
"description": "Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`.\n\nThis is nullable because decline/cancel responses have no content."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
],
|
||||
"title": "McpServerElicitationRequestResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -201,6 +201,13 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginDisplayNames": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -652,6 +652,76 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpServerElicitationRequestParams": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"form"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requestedSchema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requestedSchema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"elicitationId": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"url"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"elicitationId",
|
||||
"message",
|
||||
"mode",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"serverName": {
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"description": "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"serverName",
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkApprovalContext": {
|
||||
"properties": {
|
||||
"host": {
|
||||
@@ -981,6 +1051,31 @@
|
||||
"title": "Item/tool/requestUserInputRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Request input for an MCP server elicitation.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"mcpServer/elicitation/request"
|
||||
],
|
||||
"title": "McpServer/elicitation/requestRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/McpServerElicitationRequestParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "McpServer/elicitation/requestRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Execute a dynamic tool call on the client.",
|
||||
"properties": {
|
||||
|
||||
@@ -1774,6 +1774,56 @@
|
||||
"title": "DynamicToolCallResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ElicitationRequest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"form"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requested_schema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requested_schema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"elicitation_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"url"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"elicitation_id",
|
||||
"message",
|
||||
"mode",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"EventMsg": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.",
|
||||
@@ -3194,8 +3244,8 @@
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
"request": {
|
||||
"$ref": "#/definitions/ElicitationRequest"
|
||||
},
|
||||
"server_name": {
|
||||
"type": "string"
|
||||
@@ -3210,7 +3260,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"message",
|
||||
"request",
|
||||
"server_name",
|
||||
"type"
|
||||
],
|
||||
@@ -5259,6 +5309,102 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"McpServerElicitationAction": {
|
||||
"enum": [
|
||||
"accept",
|
||||
"decline",
|
||||
"cancel"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"McpServerElicitationRequestParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"form"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requestedSchema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requestedSchema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"elicitationId": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"url"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"elicitationId",
|
||||
"message",
|
||||
"mode",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"serverName": {
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"description": "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"serverName",
|
||||
"threadId"
|
||||
],
|
||||
"title": "McpServerElicitationRequestParams",
|
||||
"type": "object"
|
||||
},
|
||||
"McpServerElicitationRequestResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"action": {
|
||||
"$ref": "#/definitions/McpServerElicitationAction"
|
||||
},
|
||||
"content": {
|
||||
"description": "Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`.\n\nThis is nullable because decline/cancel responses have no content."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
],
|
||||
"title": "McpServerElicitationRequestResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"McpStartupFailure": {
|
||||
"properties": {
|
||||
"error": {
|
||||
@@ -6972,6 +7118,31 @@
|
||||
"title": "Item/tool/requestUserInputRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Request input for an MCP server elicitation.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"mcpServer/elicitation/request"
|
||||
],
|
||||
"title": "McpServer/elicitation/requestRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/McpServerElicitationRequestParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "McpServer/elicitation/requestRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Execute a dynamic tool call on the client.",
|
||||
"properties": {
|
||||
@@ -7820,6 +7991,13 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginDisplayNames": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -404,6 +404,13 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginDisplayNames": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -3286,6 +3293,56 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ElicitationRequest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"form"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"requested_schema": true
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"mode",
|
||||
"requested_schema"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"elicitation_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"url"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"elicitation_id",
|
||||
"message",
|
||||
"mode",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ErrorNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -4731,8 +4788,8 @@
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
"request": {
|
||||
"$ref": "#/definitions/ElicitationRequest"
|
||||
},
|
||||
"server_name": {
|
||||
"type": "string"
|
||||
@@ -4747,7 +4804,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"message",
|
||||
"request",
|
||||
"server_name",
|
||||
"type"
|
||||
],
|
||||
|
||||
@@ -119,6 +119,13 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginDisplayNames": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -119,6 +119,13 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginDisplayNames": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -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 { JsonValue } from "./serde_json/JsonValue";
|
||||
|
||||
export type ElicitationRequest = { "mode": "form", message: string, requested_schema: JsonValue, } | { "mode": "url", message: string, url: string, elicitation_id: string, };
|
||||
@@ -1,5 +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 { ElicitationRequest } from "./ElicitationRequest";
|
||||
|
||||
export type ElicitationRequestEvent = { server_name: string, id: string | number, message: string, };
|
||||
export type ElicitationRequestEvent = { server_name: string, id: string | number, request: ElicitationRequest, };
|
||||
|
||||
@@ -8,9 +8,10 @@ import type { ChatgptAuthTokensRefreshParams } from "./v2/ChatgptAuthTokensRefre
|
||||
import type { CommandExecutionRequestApprovalParams } from "./v2/CommandExecutionRequestApprovalParams";
|
||||
import type { DynamicToolCallParams } from "./v2/DynamicToolCallParams";
|
||||
import type { FileChangeRequestApprovalParams } from "./v2/FileChangeRequestApprovalParams";
|
||||
import type { McpServerElicitationRequestParams } from "./v2/McpServerElicitationRequestParams";
|
||||
import type { ToolRequestUserInputParams } from "./v2/ToolRequestUserInputParams";
|
||||
|
||||
/**
|
||||
* Request initiated from the server and sent to the client.
|
||||
*/
|
||||
export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, };
|
||||
export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, };
|
||||
|
||||
@@ -48,6 +48,7 @@ export type { DeprecationNoticeEvent } from "./DeprecationNoticeEvent";
|
||||
export type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem";
|
||||
export type { DynamicToolCallRequest } from "./DynamicToolCallRequest";
|
||||
export type { DynamicToolCallResponseEvent } from "./DynamicToolCallResponseEvent";
|
||||
export type { ElicitationRequest } from "./ElicitationRequest";
|
||||
export type { ElicitationRequestEvent } from "./ElicitationRequestEvent";
|
||||
export type { ErrorEvent } from "./ErrorEvent";
|
||||
export type { EventMsg } from "./EventMsg";
|
||||
|
||||
@@ -16,4 +16,4 @@ export type AppInfo = { id: string, name: string, description: string | null, lo
|
||||
* enabled = false
|
||||
* ```
|
||||
*/
|
||||
isEnabled: boolean, };
|
||||
isEnabled: boolean, pluginDisplayNames: Array<string>, };
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type McpServerElicitationAction = "accept" | "decline" | "cancel";
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// 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";
|
||||
|
||||
export type McpServerElicitationRequestParams = { threadId: string,
|
||||
/**
|
||||
* Active Codex turn when this elicitation was observed, if app-server could correlate one.
|
||||
*
|
||||
* This is nullable because MCP models elicitation as a standalone server-to-client request
|
||||
* identified by the MCP server request id. It may be triggered during a turn, but turn
|
||||
* context is app-server correlation rather than part of the protocol identity of the
|
||||
* elicitation itself.
|
||||
*/
|
||||
turnId: string | null, serverName: string, } & ({ "mode": "form", message: string, requestedSchema: JsonValue, } | { "mode": "url", message: string, url: string, elicitationId: string, });
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// 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";
|
||||
import type { McpServerElicitationAction } from "./McpServerElicitationAction";
|
||||
|
||||
export type McpServerElicitationRequestResponse = { action: McpServerElicitationAction,
|
||||
/**
|
||||
* Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`.
|
||||
*
|
||||
* This is nullable because decline/cancel responses have no content.
|
||||
*/
|
||||
content: JsonValue | null, };
|
||||
@@ -97,6 +97,9 @@ export type { LoginAccountParams } from "./LoginAccountParams";
|
||||
export type { LoginAccountResponse } from "./LoginAccountResponse";
|
||||
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
|
||||
export type { McpAuthStatus } from "./McpAuthStatus";
|
||||
export type { McpServerElicitationAction } from "./McpServerElicitationAction";
|
||||
export type { McpServerElicitationRequestParams } from "./McpServerElicitationRequestParams";
|
||||
export type { McpServerElicitationRequestResponse } from "./McpServerElicitationRequestResponse";
|
||||
export type { McpServerOauthLoginCompletedNotification } from "./McpServerOauthLoginCompletedNotification";
|
||||
export type { McpServerOauthLoginParams } from "./McpServerOauthLoginParams";
|
||||
export type { McpServerOauthLoginResponse } from "./McpServerOauthLoginResponse";
|
||||
|
||||
@@ -650,6 +650,12 @@ server_request_definitions! {
|
||||
response: v2::ToolRequestUserInputResponse,
|
||||
},
|
||||
|
||||
/// Request input for an MCP server elicitation.
|
||||
McpServerElicitationRequest => "mcpServer/elicitation/request" {
|
||||
params: v2::McpServerElicitationRequestParams,
|
||||
response: v2::McpServerElicitationRequestResponse,
|
||||
},
|
||||
|
||||
/// Execute a dynamic tool call on the client.
|
||||
DynamicToolCall => "item/tool/call" {
|
||||
params: v2::DynamicToolCallParams,
|
||||
@@ -1046,6 +1052,60 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_mcp_server_elicitation_request() -> Result<()> {
|
||||
let params = v2::McpServerElicitationRequestParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
turn_id: Some("turn_123".to_string()),
|
||||
server_name: "codex_apps".to_string(),
|
||||
request: v2::McpServerElicitationRequest::Form {
|
||||
message: "Allow this request?".to_string(),
|
||||
requested_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"confirmed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["confirmed"]
|
||||
}),
|
||||
},
|
||||
};
|
||||
let request = ServerRequest::McpServerElicitationRequest {
|
||||
request_id: RequestId::Integer(9),
|
||||
params: params.clone(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "mcpServer/elicitation/request",
|
||||
"id": 9,
|
||||
"params": {
|
||||
"threadId": "thr_123",
|
||||
"turnId": "turn_123",
|
||||
"serverName": "codex_apps",
|
||||
"mode": "form",
|
||||
"message": "Allow this request?",
|
||||
"requestedSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"confirmed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["confirmed"]
|
||||
}
|
||||
}
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
);
|
||||
|
||||
let payload = ServerRequestPayload::McpServerElicitationRequest(params);
|
||||
assert_eq!(request.id(), &RequestId::Integer(9));
|
||||
assert_eq!(payload.request_with_id(RequestId::Integer(9)), request);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_get_account_rate_limits() -> Result<()> {
|
||||
let request = ClientRequest::GetAccountRateLimits {
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::RequestId;
|
||||
use crate::protocol::common::AuthMode;
|
||||
use codex_experimental_api_macros::ExperimentalApi;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol;
|
||||
@@ -637,7 +638,7 @@ pub struct NetworkRequirements {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum ResidencyRequirement {
|
||||
Us,
|
||||
@@ -1708,6 +1709,8 @@ pub struct AppInfo {
|
||||
/// ```
|
||||
#[serde(default = "default_enabled")]
|
||||
pub is_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub plugin_display_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -2393,8 +2396,8 @@ pub enum HazelnutScope {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(rename_all = "lowercase")]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum ProductSurface {
|
||||
Chatgpt,
|
||||
@@ -4081,6 +4084,138 @@ pub struct FileChangeRequestApprovalResponse {
|
||||
pub decision: FileChangeApprovalDecision,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum McpServerElicitationAction {
|
||||
Accept,
|
||||
Decline,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
impl McpServerElicitationAction {
|
||||
pub fn to_core(self) -> codex_protocol::approvals::ElicitationAction {
|
||||
match self {
|
||||
Self::Accept => codex_protocol::approvals::ElicitationAction::Accept,
|
||||
Self::Decline => codex_protocol::approvals::ElicitationAction::Decline,
|
||||
Self::Cancel => codex_protocol::approvals::ElicitationAction::Cancel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<McpServerElicitationAction> for rmcp::model::ElicitationAction {
|
||||
fn from(value: McpServerElicitationAction) -> Self {
|
||||
match value {
|
||||
McpServerElicitationAction::Accept => Self::Accept,
|
||||
McpServerElicitationAction::Decline => Self::Decline,
|
||||
McpServerElicitationAction::Cancel => Self::Cancel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rmcp::model::ElicitationAction> for McpServerElicitationAction {
|
||||
fn from(value: rmcp::model::ElicitationAction) -> Self {
|
||||
match value {
|
||||
rmcp::model::ElicitationAction::Accept => Self::Accept,
|
||||
rmcp::model::ElicitationAction::Decline => Self::Decline,
|
||||
rmcp::model::ElicitationAction::Cancel => Self::Cancel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpServerElicitationRequestParams {
|
||||
pub thread_id: String,
|
||||
/// Active Codex turn when this elicitation was observed, if app-server could correlate one.
|
||||
///
|
||||
/// This is nullable because MCP models elicitation as a standalone server-to-client request
|
||||
/// identified by the MCP server request id. It may be triggered during a turn, but turn
|
||||
/// context is app-server correlation rather than part of the protocol identity of the
|
||||
/// elicitation itself.
|
||||
pub turn_id: Option<String>,
|
||||
pub server_name: String,
|
||||
#[serde(flatten)]
|
||||
pub request: McpServerElicitationRequest,
|
||||
// TODO: When core can correlate an elicitation with an MCP tool call, expose the associated
|
||||
// McpToolCall item id here as an optional field. The current core event does not carry that
|
||||
// association.
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "mode", rename_all = "camelCase")]
|
||||
#[ts(tag = "mode")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum McpServerElicitationRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Form {
|
||||
message: String,
|
||||
requested_schema: JsonValue,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Url {
|
||||
message: String,
|
||||
url: String,
|
||||
elicitation_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<CoreElicitationRequest> for McpServerElicitationRequest {
|
||||
fn from(value: CoreElicitationRequest) -> Self {
|
||||
match value {
|
||||
CoreElicitationRequest::Form {
|
||||
message,
|
||||
requested_schema,
|
||||
} => Self::Form {
|
||||
message,
|
||||
requested_schema,
|
||||
},
|
||||
CoreElicitationRequest::Url {
|
||||
message,
|
||||
url,
|
||||
elicitation_id,
|
||||
} => Self::Url {
|
||||
message,
|
||||
url,
|
||||
elicitation_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpServerElicitationRequestResponse {
|
||||
pub action: McpServerElicitationAction,
|
||||
/// Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`.
|
||||
///
|
||||
/// This is nullable because decline/cancel responses have no content.
|
||||
pub content: Option<JsonValue>,
|
||||
}
|
||||
|
||||
impl From<McpServerElicitationRequestResponse> for rmcp::model::CreateElicitationResult {
|
||||
fn from(value: McpServerElicitationRequestResponse) -> Self {
|
||||
Self {
|
||||
action: value.action.into(),
|
||||
content: value.content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rmcp::model::CreateElicitationResult> for McpServerElicitationRequestResponse {
|
||||
fn from(value: rmcp::model::CreateElicitationResult) -> Self {
|
||||
Self {
|
||||
action: value.action.into(),
|
||||
content: value.content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -4417,6 +4552,65 @@ mod tests {
|
||||
assert_eq!(back_to_v2, v2_policy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_elicitation_response_round_trips_rmcp_result() {
|
||||
let rmcp_result = rmcp::model::CreateElicitationResult {
|
||||
action: rmcp::model::ElicitationAction::Accept,
|
||||
content: Some(json!({
|
||||
"confirmed": true,
|
||||
})),
|
||||
};
|
||||
|
||||
let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone());
|
||||
assert_eq!(
|
||||
v2_response,
|
||||
McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Accept,
|
||||
content: Some(json!({
|
||||
"confirmed": true,
|
||||
})),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
rmcp::model::CreateElicitationResult::from(v2_response),
|
||||
rmcp_result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_elicitation_request_from_core_url_request() {
|
||||
let request = McpServerElicitationRequest::from(CoreElicitationRequest::Url {
|
||||
message: "Finish sign-in".to_string(),
|
||||
url: "https://example.com/complete".to_string(),
|
||||
elicitation_id: "elicitation-123".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
request,
|
||||
McpServerElicitationRequest::Url {
|
||||
message: "Finish sign-in".to_string(),
|
||||
url: "https://example.com/complete".to_string(),
|
||||
elicitation_id: "elicitation-123".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_elicitation_response_serializes_nullable_content() {
|
||||
let response = McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Decline,
|
||||
content: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).expect("response should serialize"),
|
||||
json!({
|
||||
"action": "decline",
|
||||
"content": null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_policy_round_trips_workspace_write_read_only_access() {
|
||||
let readable_root = test_absolute_path();
|
||||
|
||||
@@ -69,6 +69,7 @@ core_test_support = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
rmcp = { workspace = true, default-features = false, features = [
|
||||
"elicitation",
|
||||
"server",
|
||||
"transport-streamable-http-server",
|
||||
] }
|
||||
|
||||
@@ -764,6 +764,20 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives.
|
||||
|
||||
When the client responds to `item/tool/requestUserInput`, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup.
|
||||
|
||||
### MCP server elicitations
|
||||
|
||||
MCP servers can interrupt a turn and ask the client for structured input via `mcpServer/elicitation/request`.
|
||||
|
||||
Order of messages:
|
||||
|
||||
1. `mcpServer/elicitation/request` (request) — includes `threadId`, nullable `turnId`, `serverName`, and either:
|
||||
- a form request: `{ "mode": "form", "message": "...", "requestedSchema": { ... } }`
|
||||
- a URL request: `{ "mode": "url", "message": "...", "url": "...", "elicitationId": "..." }`
|
||||
2. Client response — `{ "action": "accept", "content": ... }`, `{ "action": "decline", "content": null }`, or `{ "action": "cancel", "content": null }`.
|
||||
3. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt.
|
||||
|
||||
`turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`.
|
||||
|
||||
### Dynamic tool calls (experimental)
|
||||
|
||||
`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`.
|
||||
|
||||
@@ -45,6 +45,9 @@ use codex_app_server_protocol::InterruptConversationResponse;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::ItemStartedNotification;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestResponse;
|
||||
use codex_app_server_protocol::McpToolCallError;
|
||||
use codex_app_server_protocol::McpToolCallResult;
|
||||
use codex_app_server_protocol::McpToolCallStatus;
|
||||
@@ -609,6 +612,38 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}
|
||||
}
|
||||
}
|
||||
EventMsg::ElicitationRequest(request) => {
|
||||
if matches!(api_version, ApiVersion::V2) {
|
||||
let permission_guard = thread_watch_manager
|
||||
.note_permission_requested(&conversation_id.to_string())
|
||||
.await;
|
||||
let turn_id = {
|
||||
let state = thread_state.lock().await;
|
||||
state.active_turn_snapshot().map(|turn| turn.id)
|
||||
};
|
||||
let params = McpServerElicitationRequestParams {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id,
|
||||
server_name: request.server_name.clone(),
|
||||
request: request.request.into(),
|
||||
};
|
||||
let (pending_request_id, rx) = outgoing
|
||||
.send_request(ServerRequestPayload::McpServerElicitationRequest(params))
|
||||
.await;
|
||||
tokio::spawn(async move {
|
||||
on_mcp_server_elicitation_response(
|
||||
request.server_name,
|
||||
request.id,
|
||||
pending_request_id,
|
||||
rx,
|
||||
conversation,
|
||||
thread_state,
|
||||
permission_guard,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
EventMsg::DynamicToolCallRequest(request) => {
|
||||
if matches!(api_version, ApiVersion::V2) {
|
||||
let call_id = request.call_id;
|
||||
@@ -1989,6 +2024,68 @@ async fn on_request_user_input_response(
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_mcp_server_elicitation_response(
|
||||
server_name: String,
|
||||
request_id: codex_protocol::mcp::RequestId,
|
||||
pending_request_id: RequestId,
|
||||
receiver: oneshot::Receiver<ClientRequestResult>,
|
||||
conversation: Arc<CodexThread>,
|
||||
thread_state: Arc<Mutex<ThreadState>>,
|
||||
permission_guard: ThreadWatchActiveGuard,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
|
||||
drop(permission_guard);
|
||||
let response = mcp_server_elicitation_response_from_client_result(response);
|
||||
|
||||
if let Err(err) = conversation
|
||||
.submit(Op::ResolveElicitation {
|
||||
server_name,
|
||||
request_id,
|
||||
decision: response.action.to_core(),
|
||||
content: response.content,
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!("failed to submit ResolveElicitation: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_server_elicitation_response_from_client_result(
|
||||
response: std::result::Result<ClientRequestResult, oneshot::error::RecvError>,
|
||||
) -> McpServerElicitationRequestResponse {
|
||||
match response {
|
||||
Ok(Ok(value)) => serde_json::from_value::<McpServerElicitationRequestResponse>(value)
|
||||
.unwrap_or_else(|err| {
|
||||
error!("failed to deserialize McpServerElicitationRequestResponse: {err}");
|
||||
McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Decline,
|
||||
content: None,
|
||||
}
|
||||
}),
|
||||
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => {
|
||||
McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Cancel,
|
||||
content: None,
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
error!("request failed with client error: {err:?}");
|
||||
McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Decline,
|
||||
content: None,
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("request failed: {err:?}");
|
||||
McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Decline,
|
||||
content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response.";
|
||||
|
||||
fn render_review_output_text(output: &ReviewOutputEvent) -> String {
|
||||
@@ -2334,6 +2431,7 @@ mod tests {
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::TurnPlanStepStatus;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
@@ -2378,6 +2476,25 @@ mod tests {
|
||||
assert_eq!(completion_status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_elicitation_turn_transition_error_maps_to_cancel() {
|
||||
let error = JSONRPCErrorError {
|
||||
code: -1,
|
||||
message: "client request resolved because the turn state was changed".to_string(),
|
||||
data: Some(serde_json::json!({ "reason": "turnTransition" })),
|
||||
};
|
||||
|
||||
let response = mcp_server_elicitation_response_from_client_result(Ok(Err(error)));
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Cancel,
|
||||
content: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_resume_begin_maps_to_item_started_resume_agent() {
|
||||
let event = CollabResumeBeginEvent {
|
||||
|
||||
@@ -124,6 +124,25 @@ enum ShutdownAction {
|
||||
Finish,
|
||||
}
|
||||
|
||||
async fn shutdown_signal() -> IoResult<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::SignalKind;
|
||||
use tokio::signal::unix::signal;
|
||||
|
||||
let mut term = signal(SignalKind::terminate())?;
|
||||
tokio::select! {
|
||||
ctrl_c_result = tokio::signal::ctrl_c() => ctrl_c_result,
|
||||
_ = term.recv() => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::signal::ctrl_c().await
|
||||
}
|
||||
}
|
||||
|
||||
impl ShutdownState {
|
||||
fn requested(&self) -> bool {
|
||||
self.requested
|
||||
@@ -133,7 +152,7 @@ impl ShutdownState {
|
||||
self.forced
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, connection_count: usize, running_turn_count: usize) {
|
||||
fn on_signal(&mut self, connection_count: usize, running_turn_count: usize) {
|
||||
if self.requested {
|
||||
self.forced = true;
|
||||
return;
|
||||
@@ -142,7 +161,7 @@ impl ShutdownState {
|
||||
self.requested = true;
|
||||
self.last_logged_running_turn_count = None;
|
||||
info!(
|
||||
"received Ctrl-C; entering graceful restart drain (connections={}, runningAssistantTurns={}, requests still accepted until no assistant turns are running)",
|
||||
"received shutdown signal; entering graceful restart drain (connections={}, runningAssistantTurns={}, requests still accepted until no assistant turns are running)",
|
||||
connection_count, running_turn_count,
|
||||
);
|
||||
}
|
||||
@@ -155,11 +174,11 @@ impl ShutdownState {
|
||||
if self.forced || running_turn_count == 0 {
|
||||
if self.forced {
|
||||
info!(
|
||||
"received second Ctrl-C; forcing restart with {running_turn_count} running assistant turn(s) and {connection_count} connection(s)"
|
||||
"received second shutdown signal; forcing restart with {running_turn_count} running assistant turn(s) and {connection_count} connection(s)"
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Ctrl-C restart: no assistant turns running; stopping acceptor and disconnecting {connection_count} connection(s)"
|
||||
"shutdown signal restart: no assistant turns running; stopping acceptor and disconnecting {connection_count} connection(s)"
|
||||
);
|
||||
}
|
||||
return ShutdownAction::Finish;
|
||||
@@ -167,7 +186,7 @@ impl ShutdownState {
|
||||
|
||||
if self.last_logged_running_turn_count != Some(running_turn_count) {
|
||||
info!(
|
||||
"Ctrl-C restart: waiting for {running_turn_count} running assistant turn(s) to finish"
|
||||
"shutdown signal restart: waiting for {running_turn_count} running assistant turn(s) to finish"
|
||||
);
|
||||
self.last_logged_running_turn_count = Some(running_turn_count);
|
||||
}
|
||||
@@ -359,8 +378,7 @@ pub async fn run_main_with_transport(
|
||||
};
|
||||
let single_client_mode = matches!(&transport_runtime, TransportRuntime::Stdio);
|
||||
let shutdown_when_no_connections = single_client_mode;
|
||||
let graceful_ctrl_c_restart_enabled = !single_client_mode;
|
||||
|
||||
let graceful_signal_restart_enabled = !single_client_mode;
|
||||
// Parse CLI overrides once and derive the base Config eagerly so later
|
||||
// components do not need to work with raw TOML values.
|
||||
let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| {
|
||||
@@ -614,14 +632,14 @@ pub async fn run_main_with_transport(
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
ctrl_c_result = tokio::signal::ctrl_c(), if graceful_ctrl_c_restart_enabled && !shutdown_state.forced() => {
|
||||
if let Err(err) = ctrl_c_result {
|
||||
warn!("failed to listen for Ctrl-C during graceful restart drain: {err}");
|
||||
shutdown_signal_result = shutdown_signal(), if graceful_signal_restart_enabled && !shutdown_state.forced() => {
|
||||
if let Err(err) = shutdown_signal_result {
|
||||
warn!("failed to listen for shutdown signal during graceful restart drain: {err}");
|
||||
}
|
||||
let running_turn_count = *running_turn_count_rx.borrow();
|
||||
shutdown_state.on_ctrl_c(connections.len(), running_turn_count);
|
||||
shutdown_state.on_signal(connections.len(), running_turn_count);
|
||||
}
|
||||
changed = running_turn_count_rx.changed(), if graceful_ctrl_c_restart_enabled && shutdown_state.requested() => {
|
||||
changed = running_turn_count_rx.changed(), if graceful_signal_restart_enabled && shutdown_state.requested() => {
|
||||
if changed.is_err() {
|
||||
warn!("running-turn watcher closed during graceful restart drain");
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
@@ -199,6 +200,7 @@ async fn list_apps_reports_is_enabled_from_config() -> Result<()> {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
@@ -308,6 +310,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -322,6 +325,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -370,6 +374,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
let first_update = read_app_list_updated_notification(&mut mcp).await?;
|
||||
@@ -389,6 +394,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -403,6 +409,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -443,6 +450,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -457,6 +465,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -516,6 +525,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -530,6 +540,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -564,6 +575,7 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let (server_url, server_handle) = start_apps_server_with_delays(
|
||||
connectors.clone(),
|
||||
@@ -619,6 +631,7 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> {
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
let update = read_app_list_updated_notification(&mut mcp).await?;
|
||||
@@ -653,6 +666,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -667,6 +681,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -724,6 +739,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
assert_eq!(first_page, expected_first);
|
||||
@@ -767,6 +783,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
assert_eq!(second_page, expected_second);
|
||||
@@ -791,6 +808,7 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
@@ -895,6 +913,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -909,6 +928,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
let initial_tools = vec![connector_tool("beta", "Beta App")?];
|
||||
@@ -958,6 +978,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}]
|
||||
);
|
||||
|
||||
@@ -978,6 +999,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -992,6 +1014,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -1021,6 +1044,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}]);
|
||||
server_control.set_tools(Vec::new());
|
||||
|
||||
@@ -1050,6 +1074,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -1064,6 +1089,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -1091,6 +1117,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let second_update = read_app_list_updated_notification(&mut mcp).await?;
|
||||
assert_eq!(second_update.data, expected_final);
|
||||
|
||||
@@ -83,6 +83,57 @@ async fn websocket_transport_second_ctrl_c_forces_exit_while_turn_running() -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_transport_sigterm_waits_for_running_turn_before_exit() -> Result<()> {
|
||||
let GracefulCtrlCFixture {
|
||||
_codex_home,
|
||||
_server,
|
||||
mut process,
|
||||
mut ws,
|
||||
} = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?;
|
||||
|
||||
send_sigterm(&process)?;
|
||||
assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?;
|
||||
|
||||
let status = wait_for_process_exit_within(
|
||||
&mut process,
|
||||
Duration::from_secs(10),
|
||||
"timed out waiting for graceful SIGTERM restart shutdown",
|
||||
)
|
||||
.await?;
|
||||
assert!(status.success(), "expected graceful exit, got {status}");
|
||||
|
||||
expect_websocket_disconnect(&mut ws).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_transport_second_sigterm_forces_exit_while_turn_running() -> Result<()> {
|
||||
let GracefulCtrlCFixture {
|
||||
_codex_home,
|
||||
_server,
|
||||
mut process,
|
||||
mut ws,
|
||||
} = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?;
|
||||
|
||||
send_sigterm(&process)?;
|
||||
assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?;
|
||||
|
||||
send_sigterm(&process)?;
|
||||
let status = wait_for_process_exit_within(
|
||||
&mut process,
|
||||
Duration::from_secs(2),
|
||||
"timed out waiting for forced SIGTERM restart shutdown",
|
||||
)
|
||||
.await?;
|
||||
assert!(status.success(), "expected graceful exit, got {status}");
|
||||
|
||||
expect_websocket_disconnect(&mut ws).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct GracefulCtrlCFixture {
|
||||
_codex_home: TempDir,
|
||||
_server: wiremock::MockServer,
|
||||
@@ -180,16 +231,24 @@ async fn wait_for_responses_post(server: &wiremock::MockServer, wait_for: Durati
|
||||
}
|
||||
|
||||
fn send_sigint(process: &Child) -> Result<()> {
|
||||
send_signal(process, "-INT")
|
||||
}
|
||||
|
||||
fn send_sigterm(process: &Child) -> Result<()> {
|
||||
send_signal(process, "-TERM")
|
||||
}
|
||||
|
||||
fn send_signal(process: &Child, signal: &str) -> Result<()> {
|
||||
let pid = process
|
||||
.id()
|
||||
.context("websocket app-server process has no pid")?;
|
||||
let status = StdCommand::new("kill")
|
||||
.arg("-INT")
|
||||
.arg(signal)
|
||||
.arg(pid.to_string())
|
||||
.status()
|
||||
.context("failed to invoke kill -INT")?;
|
||||
.with_context(|| format!("failed to invoke kill {signal}"))?;
|
||||
if !status.success() {
|
||||
bail!("kill -INT exited with {status}");
|
||||
bail!("kill {signal} exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
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::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::Uri;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::routing::get;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ServerRequestResolvedNotification;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
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_core::auth::AuthCredentialsStoreMode;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::BooleanSchema;
|
||||
use rmcp::model::CallToolRequestParams;
|
||||
use rmcp::model::CallToolResult;
|
||||
use rmcp::model::Content;
|
||||
use rmcp::model::CreateElicitationRequestParams;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use rmcp::model::ElicitationSchema;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::ListToolsResult;
|
||||
use rmcp::model::Meta;
|
||||
use rmcp::model::PrimitiveSchema;
|
||||
use rmcp::model::ServerCapabilities;
|
||||
use rmcp::model::ServerInfo;
|
||||
use rmcp::model::Tool;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
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 serde_json::Value;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const CONNECTOR_ID: &str = "calendar";
|
||||
const CONNECTOR_NAME: &str = "Calendar";
|
||||
const TOOL_NAME: &str = "calendar_confirm_action";
|
||||
const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar_confirm_action";
|
||||
const TOOL_CALL_ID: &str = "call-calendar-confirm";
|
||||
const ELICITATION_MESSAGE: &str = "Allow this request?";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn mcp_server_elicitation_round_trip() -> Result<()> {
|
||||
let responses_server = responses::start_mock_server().await;
|
||||
let tool_call_arguments = serde_json::to_string(&json!({}))?;
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
&responses_server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-0"),
|
||||
responses::ev_assistant_message("msg-0", "Warmup"),
|
||||
responses::ev_completed("resp-0"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(
|
||||
TOOL_CALL_ID,
|
||||
QUALIFIED_TOOL_NAME,
|
||||
&tool_call_arguments,
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let (apps_server_url, apps_server_handle) = start_apps_server().await?;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?;
|
||||
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 warmup_turn_start_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Warm up connectors.".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let warmup_turn_start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(warmup_turn_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: TurnStartResponse = to_response(warmup_turn_start_resp)?;
|
||||
|
||||
let warmup_completed = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
let warmup_completed: TurnCompletedNotification = serde_json::from_value(
|
||||
warmup_completed
|
||||
.params
|
||||
.clone()
|
||||
.expect("warmup turn/completed params"),
|
||||
)?;
|
||||
assert_eq!(warmup_completed.thread_id, thread.id);
|
||||
assert_eq!(warmup_completed.turn.status, TurnStatus::Completed);
|
||||
|
||||
let turn_start_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec to run the calendar tool.".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let TurnStartResponse { turn } = to_response(turn_start_resp)?;
|
||||
|
||||
let server_req = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else {
|
||||
panic!("expected McpServerElicitationRequest request, got: {server_req:?}");
|
||||
};
|
||||
let requested_schema = serde_json::to_value(
|
||||
ElicitationSchema::builder()
|
||||
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
|
||||
.build()
|
||||
.map_err(anyhow::Error::msg)?,
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
params,
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: thread.id.clone(),
|
||||
turn_id: Some(turn.id.clone()),
|
||||
server_name: "codex_apps".to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
message: ELICITATION_MESSAGE.to_string(),
|
||||
requested_schema,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let resolved_request_id = request_id.clone();
|
||||
mcp.send_response(
|
||||
request_id,
|
||||
serde_json::to_value(McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Accept,
|
||||
content: Some(json!({
|
||||
"confirmed": true,
|
||||
})),
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut saw_resolved = false;
|
||||
loop {
|
||||
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
|
||||
let JSONRPCMessage::Notification(notification) = message else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match notification.method.as_str() {
|
||||
"serverRequest/resolved" => {
|
||||
let resolved: ServerRequestResolvedNotification = serde_json::from_value(
|
||||
notification
|
||||
.params
|
||||
.clone()
|
||||
.expect("serverRequest/resolved params"),
|
||||
)?;
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ServerRequestResolvedNotification {
|
||||
thread_id: thread.id.clone(),
|
||||
request_id: resolved_request_id.clone(),
|
||||
}
|
||||
);
|
||||
saw_resolved = true;
|
||||
}
|
||||
"turn/completed" => {
|
||||
let completed: TurnCompletedNotification = serde_json::from_value(
|
||||
notification.params.clone().expect("turn/completed params"),
|
||||
)?;
|
||||
assert!(saw_resolved, "serverRequest/resolved should arrive first");
|
||||
assert_eq!(completed.thread_id, thread.id);
|
||||
assert_eq!(completed.turn.id, turn.id);
|
||||
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let requests = response_mock.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
let function_call_output = requests[2].function_call_output(TOOL_CALL_ID);
|
||||
assert_eq!(
|
||||
function_call_output.get("type"),
|
||||
Some(&Value::String("function_call_output".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
function_call_output.get("call_id"),
|
||||
Some(&Value::String(TOOL_CALL_ID.to_string()))
|
||||
);
|
||||
let output = function_call_output
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("function_call_output output should be a JSON string");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(output)?,
|
||||
json!([{
|
||||
"type": "text",
|
||||
"text": "accepted"
|
||||
}])
|
||||
);
|
||||
|
||||
apps_server_handle.abort();
|
||||
let _ = apps_server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppsServerState {
|
||||
expected_bearer: String,
|
||||
expected_account_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct ElicitationAppsMcpServer;
|
||||
|
||||
impl ServerHandler for ElicitationAppsMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
protocol_version: rmcp::model::ProtocolVersion::V_2025_06_18,
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
..ServerInfo::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
_context: RequestContext<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::Borrowed(TOOL_NAME),
|
||||
Cow::Borrowed("Confirm a calendar action."),
|
||||
Arc::new(input_schema),
|
||||
);
|
||||
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
||||
|
||||
let mut meta = Meta::new();
|
||||
meta.0
|
||||
.insert("connector_id".to_string(), json!(CONNECTOR_ID));
|
||||
meta.0
|
||||
.insert("connector_name".to_string(), json!(CONNECTOR_NAME));
|
||||
tool.meta = Some(meta);
|
||||
|
||||
Ok(ListToolsResult {
|
||||
tools: vec![tool],
|
||||
next_cursor: None,
|
||||
meta: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
_request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
let requested_schema = ElicitationSchema::builder()
|
||||
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
|
||||
.build()
|
||||
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
|
||||
|
||||
let result = context
|
||||
.peer
|
||||
.create_elicitation(CreateElicitationRequestParams::FormElicitationParams {
|
||||
meta: None,
|
||||
message: ELICITATION_MESSAGE.to_string(),
|
||||
requested_schema,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
|
||||
|
||||
let output = match result.action {
|
||||
ElicitationAction::Accept => {
|
||||
assert_eq!(
|
||||
result.content,
|
||||
Some(json!({
|
||||
"confirmed": true,
|
||||
}))
|
||||
);
|
||||
"accepted"
|
||||
}
|
||||
ElicitationAction::Decline => "declined",
|
||||
ElicitationAction::Cancel => "cancelled",
|
||||
};
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(output)]))
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_apps_server() -> Result<(String, JoinHandle<()>)> {
|
||||
let state = Arc::new(AppsServerState {
|
||||
expected_bearer: "Bearer chatgpt-token".to_string(),
|
||||
expected_account_id: "account-123".to_string(),
|
||||
});
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
|
||||
let mcp_service = StreamableHttpService::new(
|
||||
move || Ok(ElicitationAppsMcpServer),
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
|
||||
let router = Router::new()
|
||||
.route("/connectors/directory/list", get(list_directory_connectors))
|
||||
.route(
|
||||
"/connectors/directory/list_workspace",
|
||||
get(list_directory_connectors),
|
||||
)
|
||||
.with_state(state)
|
||||
.nest_service("/api/codex/apps", mcp_service);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
|
||||
Ok((format!("http://{addr}"), handle))
|
||||
}
|
||||
|
||||
async fn list_directory_connectors(
|
||||
State(state): State<Arc<AppsServerState>>,
|
||||
headers: HeaderMap,
|
||||
uri: Uri,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let bearer_ok = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value == state.expected_bearer);
|
||||
let account_ok = headers
|
||||
.get("chatgpt-account-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value == state.expected_account_id);
|
||||
let external_logos_ok = uri
|
||||
.query()
|
||||
.is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true"));
|
||||
|
||||
if !bearer_ok || !account_ok {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
} else if !external_logos_ok {
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
} else {
|
||||
Ok(Json(json!({
|
||||
"apps": [{
|
||||
"id": CONNECTOR_ID,
|
||||
"name": CONNECTOR_NAME,
|
||||
"description": "Calendar connector",
|
||||
"logo_url": null,
|
||||
"logo_url_dark": null,
|
||||
"distribution_channel": null,
|
||||
"branding": null,
|
||||
"app_metadata": null,
|
||||
"labels": null,
|
||||
"install_url": null,
|
||||
"is_accessible": false,
|
||||
"is_enabled": true
|
||||
}],
|
||||
"next_token": null
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_config_toml(
|
||||
codex_home: &std::path::Path,
|
||||
responses_server_uri: &str,
|
||||
apps_server_url: &str,
|
||||
) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
codex_home.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
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod dynamic_tools;
|
||||
mod experimental_api;
|
||||
mod experimental_feature_list;
|
||||
mod initialize;
|
||||
mod mcp_server_elicitation;
|
||||
mod model_list;
|
||||
mod output_schema;
|
||||
mod plan_item;
|
||||
|
||||
@@ -13,6 +13,7 @@ tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "io-util", "process", "time"] }
|
||||
url = { workspace = true }
|
||||
which = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# codex-artifacts
|
||||
|
||||
Runtime and process-management helpers for Codex artifact generation.
|
||||
|
||||
This crate has two main responsibilities:
|
||||
|
||||
- locating, validating, and optionally downloading the pinned artifact runtime
|
||||
- spawning the artifact build or render command against that runtime
|
||||
|
||||
## Module layout
|
||||
|
||||
- `src/client.rs`
|
||||
Runs build and render commands once a runtime has been resolved.
|
||||
- `src/runtime/manager.rs`
|
||||
Defines the release locator and the package-manager-backed runtime installer.
|
||||
- `src/runtime/installed.rs`
|
||||
Loads an extracted runtime from disk and validates its manifest and entrypoints.
|
||||
- `src/runtime/js_runtime.rs`
|
||||
Chooses the JavaScript executable to use for artifact execution.
|
||||
- `src/runtime/manifest.rs`
|
||||
Manifest types for release metadata and extracted runtimes.
|
||||
- `src/runtime/error.rs`
|
||||
Public runtime-loading and installation errors.
|
||||
- `src/tests.rs`
|
||||
Crate-level tests that exercise the public API and integration seams.
|
||||
|
||||
## Public API
|
||||
|
||||
- `ArtifactRuntimeManager`
|
||||
Resolves or installs a runtime package into `~/.codex/packages/artifacts/...`.
|
||||
- `load_cached_runtime`
|
||||
Reads a previously installed runtime from a caller-provided cache root without attempting a download.
|
||||
- `is_js_runtime_available`
|
||||
Checks whether artifact execution is possible with either a cached runtime or a host JS runtime.
|
||||
- `ArtifactsClient`
|
||||
Executes artifact build or render requests using either a managed or preinstalled runtime.
|
||||
@@ -14,35 +14,41 @@ use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Executes artifact build and render commands against a resolved runtime.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtifactsClient {
|
||||
runtime_source: RuntimeSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum RuntimeSource {
|
||||
Managed(ArtifactRuntimeManager),
|
||||
Installed(InstalledArtifactRuntime),
|
||||
}
|
||||
|
||||
impl ArtifactsClient {
|
||||
/// Creates a client that lazily resolves or downloads the runtime on demand.
|
||||
pub fn from_runtime_manager(runtime_manager: ArtifactRuntimeManager) -> Self {
|
||||
Self {
|
||||
runtime_source: RuntimeSource::Managed(runtime_manager),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a client pinned to an already loaded runtime.
|
||||
pub fn from_installed_runtime(runtime: InstalledArtifactRuntime) -> Self {
|
||||
Self {
|
||||
runtime_source: RuntimeSource::Installed(runtime),
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes artifact-building JavaScript against the configured runtime.
|
||||
pub async fn execute_build(
|
||||
&self,
|
||||
request: ArtifactBuildRequest,
|
||||
) -> Result<ArtifactCommandOutput, ArtifactsError> {
|
||||
let runtime = self.resolve_runtime().await?;
|
||||
let js_runtime = runtime.resolve_js_runtime()?;
|
||||
let staging_dir = TempDir::new().map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to create build staging directory".to_string(),
|
||||
source,
|
||||
@@ -56,7 +62,7 @@ impl ArtifactsClient {
|
||||
source,
|
||||
})?;
|
||||
|
||||
let mut command = Command::new(runtime.node_path());
|
||||
let mut command = Command::new(js_runtime.executable_path());
|
||||
command
|
||||
.arg(&script_path)
|
||||
.current_dir(&request.cwd)
|
||||
@@ -67,6 +73,9 @@ impl ArtifactsClient {
|
||||
)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if js_runtime.requires_electron_run_as_node() {
|
||||
command.env("ELECTRON_RUN_AS_NODE", "1");
|
||||
}
|
||||
for (key, value) in &request.env {
|
||||
command.env(key, value);
|
||||
}
|
||||
@@ -78,18 +87,23 @@ impl ArtifactsClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Executes the artifact render CLI against the configured runtime.
|
||||
pub async fn execute_render(
|
||||
&self,
|
||||
request: ArtifactRenderCommandRequest,
|
||||
) -> Result<ArtifactCommandOutput, ArtifactsError> {
|
||||
let runtime = self.resolve_runtime().await?;
|
||||
let mut command = Command::new(runtime.node_path());
|
||||
let js_runtime = runtime.resolve_js_runtime()?;
|
||||
let mut command = Command::new(js_runtime.executable_path());
|
||||
command
|
||||
.arg(runtime.render_cli_path())
|
||||
.args(request.target.to_args())
|
||||
.current_dir(&request.cwd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if js_runtime.requires_electron_run_as_node() {
|
||||
command.env("ELECTRON_RUN_AS_NODE", "1");
|
||||
}
|
||||
for (key, value) in &request.env {
|
||||
command.env(key, value);
|
||||
}
|
||||
@@ -109,6 +123,7 @@ impl ArtifactsClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request payload for the artifact build command.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ArtifactBuildRequest {
|
||||
pub source: String,
|
||||
@@ -117,6 +132,7 @@ pub struct ArtifactBuildRequest {
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Request payload for the artifact render CLI.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtifactRenderCommandRequest {
|
||||
pub cwd: PathBuf,
|
||||
@@ -125,6 +141,7 @@ pub struct ArtifactRenderCommandRequest {
|
||||
pub target: ArtifactRenderTarget,
|
||||
}
|
||||
|
||||
/// Render targets supported by the packaged artifact runtime.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ArtifactRenderTarget {
|
||||
Presentation(PresentationRenderTarget),
|
||||
@@ -132,6 +149,7 @@ pub enum ArtifactRenderTarget {
|
||||
}
|
||||
|
||||
impl ArtifactRenderTarget {
|
||||
/// Converts a render target to the CLI args expected by `render_cli.mjs`.
|
||||
pub fn to_args(&self) -> Vec<String> {
|
||||
match self {
|
||||
Self::Presentation(target) => {
|
||||
@@ -167,6 +185,7 @@ impl ArtifactRenderTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Presentation render request parameters.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PresentationRenderTarget {
|
||||
pub input_path: PathBuf,
|
||||
@@ -174,6 +193,7 @@ pub struct PresentationRenderTarget {
|
||||
pub slide_number: u32,
|
||||
}
|
||||
|
||||
/// Spreadsheet render request parameters.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SpreadsheetRenderTarget {
|
||||
pub input_path: PathBuf,
|
||||
@@ -182,6 +202,7 @@ pub struct SpreadsheetRenderTarget {
|
||||
pub range: Option<String>,
|
||||
}
|
||||
|
||||
/// Captured stdout, stderr, and exit status from an artifact subprocess.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactCommandOutput {
|
||||
pub exit_code: Option<i32>,
|
||||
@@ -190,11 +211,13 @@ pub struct ArtifactCommandOutput {
|
||||
}
|
||||
|
||||
impl ArtifactCommandOutput {
|
||||
/// Returns whether the subprocess exited successfully.
|
||||
pub fn success(&self) -> bool {
|
||||
self.exit_code == Some(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors raised while spawning or awaiting artifact subprocesses.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ArtifactsError {
|
||||
#[error(transparent)]
|
||||
@@ -294,153 +317,3 @@ async fn run_command(
|
||||
stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
use crate::ArtifactRuntimePlatform;
|
||||
#[cfg(unix)]
|
||||
use crate::ExtractedRuntimeManifest;
|
||||
#[cfg(unix)]
|
||||
use crate::RuntimeEntrypoints;
|
||||
#[cfg(unix)]
|
||||
use crate::RuntimePathEntry;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[test]
|
||||
fn wrapped_build_script_exposes_artifact_tool_surface() {
|
||||
let wrapped = build_wrapped_script("console.log(Object.keys(artifactTool).length);");
|
||||
assert!(wrapped.contains("const artifactTool = await import("));
|
||||
assert!(wrapped.contains("globalThis.artifactTool = artifactTool;"));
|
||||
assert!(wrapped.contains("globalThis.artifacts = artifactTool;"));
|
||||
assert!(wrapped.contains("globalThis.codexArtifacts = artifactTool;"));
|
||||
assert!(wrapped.contains("Object.entries(artifactTool)"));
|
||||
assert!(wrapped.contains("globalThis[name] = value;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_render_target_builds_expected_args() {
|
||||
let args = ArtifactRenderTarget::Presentation(PresentationRenderTarget {
|
||||
input_path: PathBuf::from("deck.pptx"),
|
||||
output_path: PathBuf::from("slide.png"),
|
||||
slide_number: 2,
|
||||
})
|
||||
.to_args();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"pptx",
|
||||
"render",
|
||||
"--in",
|
||||
"deck.pptx",
|
||||
"--slide",
|
||||
"2",
|
||||
"--out",
|
||||
"slide.png"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreadsheet_render_target_builds_expected_args() {
|
||||
let args = ArtifactRenderTarget::Spreadsheet(SpreadsheetRenderTarget {
|
||||
input_path: PathBuf::from("book.xlsx"),
|
||||
output_path: PathBuf::from("sheet.png"),
|
||||
sheet_name: "Summary".to_string(),
|
||||
range: Some("A1:C3".to_string()),
|
||||
})
|
||||
.to_args();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"xlsx",
|
||||
"render",
|
||||
"--in",
|
||||
"book.xlsx",
|
||||
"--sheet",
|
||||
"Summary",
|
||||
"--out",
|
||||
"sheet.png",
|
||||
"--range",
|
||||
"A1:C3"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn execute_build_invokes_runtime_node_with_expected_environment() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let cwd = temp.path().join("cwd");
|
||||
fs::create_dir_all(&cwd)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
let log_path = temp.path().join("build.log");
|
||||
let fake_node = temp.path().join("fake-node.sh");
|
||||
let build_entrypoint = temp.path().join("artifact_tool.mjs");
|
||||
let render_entrypoint = temp.path().join("render_cli.mjs");
|
||||
fs::write(
|
||||
&fake_node,
|
||||
format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$1\" > \"{}\"\nprintf '%s\\n' \"$CODEX_ARTIFACT_BUILD_ENTRYPOINT\" >> \"{}\"\n",
|
||||
log_path.display(),
|
||||
log_path.display()
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
std::fs::set_permissions(&fake_node, std::fs::Permissions::from_mode(0o755))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
let runtime = InstalledArtifactRuntime::new(
|
||||
temp.path().join("runtime"),
|
||||
"0.1.0".to_string(),
|
||||
ArtifactRuntimePlatform::LinuxX64,
|
||||
sample_manifest("0.1.0"),
|
||||
fake_node.clone(),
|
||||
build_entrypoint.clone(),
|
||||
render_entrypoint,
|
||||
);
|
||||
let client = ArtifactsClient::from_installed_runtime(runtime);
|
||||
|
||||
let output = client
|
||||
.execute_build(ArtifactBuildRequest {
|
||||
source: "console.log('hello');".to_string(),
|
||||
cwd: cwd.clone(),
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
env: BTreeMap::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert!(output.success());
|
||||
let logged = fs::read_to_string(&log_path)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
assert!(logged.contains("artifact-build.mjs"));
|
||||
assert!(logged.contains(&build_entrypoint.display().to_string()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sample_manifest(runtime_version: &str) -> ExtractedRuntimeManifest {
|
||||
ExtractedRuntimeManifest {
|
||||
schema_version: 1,
|
||||
runtime_version: runtime_version.to_string(),
|
||||
node: RuntimePathEntry {
|
||||
relative_path: "node/bin/node".to_string(),
|
||||
},
|
||||
entrypoints: RuntimeEntrypoints {
|
||||
build_js: RuntimePathEntry {
|
||||
relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(),
|
||||
},
|
||||
render_cli: RuntimePathEntry {
|
||||
relative_path: "granola-render/dist/cli.mjs".to_string(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
mod client;
|
||||
mod runtime;
|
||||
#[cfg(all(test, not(windows)))]
|
||||
mod tests;
|
||||
|
||||
pub use client::ArtifactBuildRequest;
|
||||
pub use client::ArtifactCommandOutput;
|
||||
@@ -15,9 +17,15 @@ pub use runtime::ArtifactRuntimeManagerConfig;
|
||||
pub use runtime::ArtifactRuntimePlatform;
|
||||
pub use runtime::ArtifactRuntimeReleaseLocator;
|
||||
pub use runtime::DEFAULT_CACHE_ROOT_RELATIVE;
|
||||
pub use runtime::DEFAULT_RELEASE_BASE_URL;
|
||||
pub use runtime::DEFAULT_RELEASE_TAG_PREFIX;
|
||||
pub use runtime::ExtractedRuntimeManifest;
|
||||
pub use runtime::InstalledArtifactRuntime;
|
||||
pub use runtime::JsRuntime;
|
||||
pub use runtime::JsRuntimeKind;
|
||||
pub use runtime::ReleaseManifest;
|
||||
pub use runtime::RuntimeEntrypoints;
|
||||
pub use runtime::RuntimePathEntry;
|
||||
pub use runtime::can_manage_artifact_runtime;
|
||||
pub use runtime::is_js_runtime_available;
|
||||
pub use runtime::load_cached_runtime;
|
||||
|
||||
@@ -1,522 +0,0 @@
|
||||
use codex_package_manager::ManagedPackage;
|
||||
use codex_package_manager::PackageManager;
|
||||
use codex_package_manager::PackageManagerConfig;
|
||||
use codex_package_manager::PackageManagerError;
|
||||
pub use codex_package_manager::PackagePlatform as ArtifactRuntimePlatform;
|
||||
use codex_package_manager::PackageReleaseArchive;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
pub const DEFAULT_RELEASE_TAG_PREFIX: &str = "artifact-runtime-v";
|
||||
pub const DEFAULT_CACHE_ROOT_RELATIVE: &str = "packages/artifacts";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactRuntimeReleaseLocator {
|
||||
base_url: Url,
|
||||
runtime_version: String,
|
||||
release_tag_prefix: String,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeReleaseLocator {
|
||||
pub fn new(base_url: Url, runtime_version: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
runtime_version: runtime_version.into(),
|
||||
release_tag_prefix: DEFAULT_RELEASE_TAG_PREFIX.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_tag_prefix(mut self, release_tag_prefix: impl Into<String>) -> Self {
|
||||
self.release_tag_prefix = release_tag_prefix.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &Url {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
pub fn runtime_version(&self) -> &str {
|
||||
&self.runtime_version
|
||||
}
|
||||
|
||||
pub fn release_tag(&self) -> String {
|
||||
format!("{}{}", self.release_tag_prefix, self.runtime_version)
|
||||
}
|
||||
|
||||
pub fn manifest_file_name(&self) -> String {
|
||||
format!("{}-manifest.json", self.release_tag())
|
||||
}
|
||||
|
||||
pub fn manifest_url(&self) -> Result<Url, PackageManagerError> {
|
||||
self.base_url
|
||||
.join(&self.manifest_file_name())
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactRuntimeManagerConfig {
|
||||
package_manager: PackageManagerConfig<ArtifactRuntimePackage>,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeManagerConfig {
|
||||
pub fn new(codex_home: PathBuf, release: ArtifactRuntimeReleaseLocator) -> Self {
|
||||
Self {
|
||||
package_manager: PackageManagerConfig::new(
|
||||
codex_home,
|
||||
ArtifactRuntimePackage::new(release),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_cache_root(mut self, cache_root: PathBuf) -> Self {
|
||||
self.package_manager = self.package_manager.with_cache_root(cache_root);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cache_root(&self) -> PathBuf {
|
||||
self.package_manager.cache_root()
|
||||
}
|
||||
|
||||
pub fn release(&self) -> &ArtifactRuntimeReleaseLocator {
|
||||
&self.package_manager.package().release
|
||||
}
|
||||
|
||||
pub fn codex_home(&self) -> &Path {
|
||||
self.package_manager.codex_home()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtifactRuntimeManager {
|
||||
package_manager: PackageManager<ArtifactRuntimePackage>,
|
||||
config: ArtifactRuntimeManagerConfig,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeManager {
|
||||
pub fn new(config: ArtifactRuntimeManagerConfig) -> Self {
|
||||
let package_manager = PackageManager::new(config.package_manager.clone());
|
||||
Self {
|
||||
package_manager,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_client(config: ArtifactRuntimeManagerConfig, client: Client) -> Self {
|
||||
let package_manager = PackageManager::with_client(config.package_manager.clone(), client);
|
||||
Self {
|
||||
package_manager,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &ArtifactRuntimeManagerConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub async fn resolve_cached(
|
||||
&self,
|
||||
) -> Result<Option<InstalledArtifactRuntime>, ArtifactRuntimeError> {
|
||||
self.package_manager.resolve_cached().await
|
||||
}
|
||||
|
||||
pub async fn ensure_installed(&self) -> Result<InstalledArtifactRuntime, ArtifactRuntimeError> {
|
||||
self.package_manager.ensure_installed().await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ArtifactRuntimePackage {
|
||||
release: ArtifactRuntimeReleaseLocator,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimePackage {
|
||||
fn new(release: ArtifactRuntimeReleaseLocator) -> Self {
|
||||
Self { release }
|
||||
}
|
||||
}
|
||||
|
||||
impl ManagedPackage for ArtifactRuntimePackage {
|
||||
type Error = ArtifactRuntimeError;
|
||||
type Installed = InstalledArtifactRuntime;
|
||||
type ReleaseManifest = ReleaseManifest;
|
||||
|
||||
fn default_cache_root_relative(&self) -> &str {
|
||||
DEFAULT_CACHE_ROOT_RELATIVE
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
self.release.runtime_version()
|
||||
}
|
||||
|
||||
fn manifest_url(&self) -> Result<Url, PackageManagerError> {
|
||||
self.release.manifest_url()
|
||||
}
|
||||
|
||||
fn archive_url(&self, archive: &PackageReleaseArchive) -> Result<Url, PackageManagerError> {
|
||||
self.release
|
||||
.base_url()
|
||||
.join(&archive.archive)
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
|
||||
fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str {
|
||||
&manifest.runtime_version
|
||||
}
|
||||
|
||||
fn platform_archive(
|
||||
&self,
|
||||
manifest: &Self::ReleaseManifest,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<PackageReleaseArchive, Self::Error> {
|
||||
manifest
|
||||
.platforms
|
||||
.get(platform.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
PackageManagerError::MissingPlatform(platform.as_str().to_string()).into()
|
||||
})
|
||||
}
|
||||
|
||||
fn install_dir(&self, cache_root: &Path, platform: ArtifactRuntimePlatform) -> PathBuf {
|
||||
cache_root.join(self.version()).join(platform.as_str())
|
||||
}
|
||||
|
||||
fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str {
|
||||
package.runtime_version()
|
||||
}
|
||||
|
||||
fn load_installed(
|
||||
&self,
|
||||
root_dir: PathBuf,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<Self::Installed, Self::Error> {
|
||||
InstalledArtifactRuntime::load(root_dir, platform)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ReleaseManifest {
|
||||
pub schema_version: u32,
|
||||
pub runtime_version: String,
|
||||
pub release_tag: String,
|
||||
#[serde(default)]
|
||||
pub node_version: Option<String>,
|
||||
pub platforms: BTreeMap<String, PackageReleaseArchive>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ExtractedRuntimeManifest {
|
||||
pub schema_version: u32,
|
||||
pub runtime_version: String,
|
||||
pub node: RuntimePathEntry,
|
||||
pub entrypoints: RuntimeEntrypoints,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct RuntimePathEntry {
|
||||
pub relative_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct RuntimeEntrypoints {
|
||||
pub build_js: RuntimePathEntry,
|
||||
pub render_cli: RuntimePathEntry,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InstalledArtifactRuntime {
|
||||
root_dir: PathBuf,
|
||||
runtime_version: String,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
manifest: ExtractedRuntimeManifest,
|
||||
node_path: PathBuf,
|
||||
build_js_path: PathBuf,
|
||||
render_cli_path: PathBuf,
|
||||
}
|
||||
|
||||
impl InstalledArtifactRuntime {
|
||||
pub fn new(
|
||||
root_dir: PathBuf,
|
||||
runtime_version: String,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
manifest: ExtractedRuntimeManifest,
|
||||
node_path: PathBuf,
|
||||
build_js_path: PathBuf,
|
||||
render_cli_path: PathBuf,
|
||||
) -> Self {
|
||||
Self {
|
||||
root_dir,
|
||||
runtime_version,
|
||||
platform,
|
||||
manifest,
|
||||
node_path,
|
||||
build_js_path,
|
||||
render_cli_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(
|
||||
root_dir: PathBuf,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<Self, ArtifactRuntimeError> {
|
||||
let manifest_path = root_dir.join("manifest.json");
|
||||
let manifest_bytes =
|
||||
std::fs::read(&manifest_path).map_err(|source| ArtifactRuntimeError::Io {
|
||||
context: format!("failed to read {}", manifest_path.display()),
|
||||
source,
|
||||
})?;
|
||||
let manifest = serde_json::from_slice::<ExtractedRuntimeManifest>(&manifest_bytes)
|
||||
.map_err(|source| ArtifactRuntimeError::InvalidManifest {
|
||||
path: manifest_path,
|
||||
source,
|
||||
})?;
|
||||
let node_path = resolve_relative_runtime_path(&root_dir, &manifest.node.relative_path)?;
|
||||
let build_js_path =
|
||||
resolve_relative_runtime_path(&root_dir, &manifest.entrypoints.build_js.relative_path)?;
|
||||
let render_cli_path = resolve_relative_runtime_path(
|
||||
&root_dir,
|
||||
&manifest.entrypoints.render_cli.relative_path,
|
||||
)?;
|
||||
|
||||
Ok(Self::new(
|
||||
root_dir,
|
||||
manifest.runtime_version.clone(),
|
||||
platform,
|
||||
manifest,
|
||||
node_path,
|
||||
build_js_path,
|
||||
render_cli_path,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn root_dir(&self) -> &Path {
|
||||
&self.root_dir
|
||||
}
|
||||
|
||||
pub fn runtime_version(&self) -> &str {
|
||||
&self.runtime_version
|
||||
}
|
||||
|
||||
pub fn platform(&self) -> ArtifactRuntimePlatform {
|
||||
self.platform
|
||||
}
|
||||
|
||||
pub fn manifest(&self) -> &ExtractedRuntimeManifest {
|
||||
&self.manifest
|
||||
}
|
||||
|
||||
pub fn node_path(&self) -> &Path {
|
||||
&self.node_path
|
||||
}
|
||||
|
||||
pub fn build_js_path(&self) -> &Path {
|
||||
&self.build_js_path
|
||||
}
|
||||
|
||||
pub fn render_cli_path(&self) -> &Path {
|
||||
&self.render_cli_path
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ArtifactRuntimeError {
|
||||
#[error(transparent)]
|
||||
PackageManager(#[from] PackageManagerError),
|
||||
#[error("{context}")]
|
||||
Io {
|
||||
context: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("invalid manifest at {path}")]
|
||||
InvalidManifest {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("runtime path `{0}` is invalid")]
|
||||
InvalidRuntimePath(String),
|
||||
}
|
||||
|
||||
fn resolve_relative_runtime_path(
|
||||
root_dir: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<PathBuf, ArtifactRuntimeError> {
|
||||
let relative = Path::new(relative_path);
|
||||
if relative.as_os_str().is_empty() || relative.is_absolute() {
|
||||
return Err(ArtifactRuntimeError::InvalidRuntimePath(
|
||||
relative_path.to_string(),
|
||||
));
|
||||
}
|
||||
if relative.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::Prefix(_) | Component::RootDir
|
||||
)
|
||||
}) {
|
||||
return Err(ArtifactRuntimeError::InvalidRuntimePath(
|
||||
relative_path.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(root_dir.join(relative))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::io::Cursor;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
#[test]
|
||||
fn release_locator_builds_manifest_url() {
|
||||
let locator = ArtifactRuntimeReleaseLocator::new(
|
||||
Url::parse("https://example.test/releases/").unwrap_or_else(|error| panic!("{error}")),
|
||||
"0.1.0",
|
||||
);
|
||||
let url = locator
|
||||
.manifest_url()
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://example.test/releases/artifact-runtime-v0.1.0-manifest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_downloads_and_extracts_zip_runtime() {
|
||||
let server = MockServer::start().await;
|
||||
let runtime_version = "0.1.0";
|
||||
let platform =
|
||||
ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!(
|
||||
"artifact-runtime-v{runtime_version}-{}.zip",
|
||||
platform.as_str()
|
||||
);
|
||||
let archive_bytes = build_zip_archive(runtime_version);
|
||||
let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes));
|
||||
let manifest = ReleaseManifest {
|
||||
schema_version: 1,
|
||||
runtime_version: runtime_version.to_string(),
|
||||
release_tag: format!("artifact-runtime-v{runtime_version}"),
|
||||
node_version: Some("22.0.0".to_string()),
|
||||
platforms: BTreeMap::from([(
|
||||
platform.as_str().to_string(),
|
||||
PackageReleaseArchive {
|
||||
archive: archive_name.clone(),
|
||||
sha256: archive_sha,
|
||||
format: codex_package_manager::ArchiveFormat::Zip,
|
||||
size_bytes: Some(archive_bytes.len() as u64),
|
||||
},
|
||||
)]),
|
||||
};
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!(
|
||||
"/artifact-runtime-v{runtime_version}-manifest.json"
|
||||
)))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{archive_name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let locator = ArtifactRuntimeReleaseLocator::new(
|
||||
Url::parse(&format!("{}/", server.uri())).unwrap_or_else(|error| panic!("{error}")),
|
||||
runtime_version,
|
||||
);
|
||||
let manager = ArtifactRuntimeManager::new(ArtifactRuntimeManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
locator,
|
||||
));
|
||||
|
||||
let runtime = manager
|
||||
.ensure_installed()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(runtime.runtime_version(), runtime_version);
|
||||
assert_eq!(runtime.platform(), platform);
|
||||
assert!(runtime.node_path().ends_with(Path::new("node/bin/node")));
|
||||
assert!(
|
||||
runtime
|
||||
.build_js_path()
|
||||
.ends_with(Path::new("artifact-tool/dist/artifact_tool.mjs"))
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.render_cli_path()
|
||||
.ends_with(Path::new("granola-render/dist/cli.mjs"))
|
||||
);
|
||||
}
|
||||
|
||||
fn build_zip_archive(runtime_version: &str) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut zip = ZipWriter::new(&mut bytes);
|
||||
let options = SimpleFileOptions::default();
|
||||
let manifest = serde_json::to_vec(&sample_extracted_manifest(runtime_version))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("artifact-runtime/manifest.json", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(&manifest)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("artifact-runtime/node/bin/node", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"#!/bin/sh\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file(
|
||||
"artifact-runtime/artifact-tool/dist/artifact_tool.mjs",
|
||||
options,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"export const ok = true;\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("artifact-runtime/granola-render/dist/cli.mjs", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"export const ok = true;\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
fn sample_extracted_manifest(runtime_version: &str) -> ExtractedRuntimeManifest {
|
||||
ExtractedRuntimeManifest {
|
||||
schema_version: 1,
|
||||
runtime_version: runtime_version.to_string(),
|
||||
node: RuntimePathEntry {
|
||||
relative_path: "node/bin/node".to_string(),
|
||||
},
|
||||
entrypoints: RuntimeEntrypoints {
|
||||
build_js: RuntimePathEntry {
|
||||
relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(),
|
||||
},
|
||||
render_cli: RuntimePathEntry {
|
||||
relative_path: "granola-render/dist/cli.mjs".to_string(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use codex_package_manager::PackageManagerError;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors raised while locating, validating, or installing an artifact runtime.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ArtifactRuntimeError {
|
||||
#[error(transparent)]
|
||||
PackageManager(#[from] PackageManagerError),
|
||||
#[error("{context}")]
|
||||
Io {
|
||||
context: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("invalid manifest at {path}")]
|
||||
InvalidManifest {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("runtime path `{0}` is invalid")]
|
||||
InvalidRuntimePath(String),
|
||||
#[error(
|
||||
"no compatible JavaScript runtime found for artifact runtime at {root_dir}; install Node or the Codex desktop app"
|
||||
)]
|
||||
MissingJsRuntime { root_dir: PathBuf },
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::ArtifactRuntimeError;
|
||||
use super::ArtifactRuntimePlatform;
|
||||
use super::ExtractedRuntimeManifest;
|
||||
use super::JsRuntime;
|
||||
use super::codex_app_runtime_candidates;
|
||||
use super::resolve_js_runtime_from_candidates;
|
||||
use super::system_electron_runtime;
|
||||
use super::system_node_runtime;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Loads a previously installed runtime from a caller-provided cache root.
|
||||
pub fn load_cached_runtime(
|
||||
cache_root: &Path,
|
||||
runtime_version: &str,
|
||||
) -> Result<InstalledArtifactRuntime, ArtifactRuntimeError> {
|
||||
let platform = ArtifactRuntimePlatform::detect_current()?;
|
||||
let install_dir = cached_runtime_install_dir(cache_root, runtime_version, platform);
|
||||
if !install_dir.exists() {
|
||||
return Err(ArtifactRuntimeError::Io {
|
||||
context: format!(
|
||||
"artifact runtime {runtime_version} is not installed at {}",
|
||||
install_dir.display()
|
||||
),
|
||||
source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing artifact runtime"),
|
||||
});
|
||||
}
|
||||
|
||||
InstalledArtifactRuntime::load(install_dir, platform)
|
||||
}
|
||||
|
||||
/// A validated runtime installation extracted into the local package cache.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InstalledArtifactRuntime {
|
||||
root_dir: PathBuf,
|
||||
runtime_version: String,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
manifest: ExtractedRuntimeManifest,
|
||||
node_path: PathBuf,
|
||||
build_js_path: PathBuf,
|
||||
render_cli_path: PathBuf,
|
||||
}
|
||||
|
||||
impl InstalledArtifactRuntime {
|
||||
/// Creates an installed-runtime value from prevalidated paths.
|
||||
pub fn new(
|
||||
root_dir: PathBuf,
|
||||
runtime_version: String,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
manifest: ExtractedRuntimeManifest,
|
||||
node_path: PathBuf,
|
||||
build_js_path: PathBuf,
|
||||
render_cli_path: PathBuf,
|
||||
) -> Self {
|
||||
Self {
|
||||
root_dir,
|
||||
runtime_version,
|
||||
platform,
|
||||
manifest,
|
||||
node_path,
|
||||
build_js_path,
|
||||
render_cli_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads and validates an extracted runtime directory.
|
||||
pub fn load(
|
||||
root_dir: PathBuf,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<Self, ArtifactRuntimeError> {
|
||||
let manifest_path = root_dir.join("manifest.json");
|
||||
let manifest_bytes =
|
||||
std::fs::read(&manifest_path).map_err(|source| ArtifactRuntimeError::Io {
|
||||
context: format!("failed to read {}", manifest_path.display()),
|
||||
source,
|
||||
})?;
|
||||
let manifest = serde_json::from_slice::<ExtractedRuntimeManifest>(&manifest_bytes)
|
||||
.map_err(|source| ArtifactRuntimeError::InvalidManifest {
|
||||
path: manifest_path,
|
||||
source,
|
||||
})?;
|
||||
let node_path = resolve_relative_runtime_path(&root_dir, &manifest.node.relative_path)?;
|
||||
let build_js_path =
|
||||
resolve_relative_runtime_path(&root_dir, &manifest.entrypoints.build_js.relative_path)?;
|
||||
let render_cli_path = resolve_relative_runtime_path(
|
||||
&root_dir,
|
||||
&manifest.entrypoints.render_cli.relative_path,
|
||||
)?;
|
||||
verify_required_runtime_path(&build_js_path)?;
|
||||
verify_required_runtime_path(&render_cli_path)?;
|
||||
|
||||
Ok(Self::new(
|
||||
root_dir,
|
||||
manifest.runtime_version.clone(),
|
||||
platform,
|
||||
manifest,
|
||||
node_path,
|
||||
build_js_path,
|
||||
render_cli_path,
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns the extracted runtime root directory.
|
||||
pub fn root_dir(&self) -> &Path {
|
||||
&self.root_dir
|
||||
}
|
||||
|
||||
/// Returns the runtime version recorded in the extracted manifest.
|
||||
pub fn runtime_version(&self) -> &str {
|
||||
&self.runtime_version
|
||||
}
|
||||
|
||||
/// Returns the platform this runtime was installed for.
|
||||
pub fn platform(&self) -> ArtifactRuntimePlatform {
|
||||
self.platform
|
||||
}
|
||||
|
||||
/// Returns the parsed extracted-runtime manifest.
|
||||
pub fn manifest(&self) -> &ExtractedRuntimeManifest {
|
||||
&self.manifest
|
||||
}
|
||||
|
||||
/// Returns the bundled Node executable path advertised by the runtime manifest.
|
||||
pub fn node_path(&self) -> &Path {
|
||||
&self.node_path
|
||||
}
|
||||
|
||||
/// Returns the artifact build entrypoint path.
|
||||
pub fn build_js_path(&self) -> &Path {
|
||||
&self.build_js_path
|
||||
}
|
||||
|
||||
/// Returns the artifact render CLI entrypoint path.
|
||||
pub fn render_cli_path(&self) -> &Path {
|
||||
&self.render_cli_path
|
||||
}
|
||||
|
||||
/// Resolves the best executable to use for artifact commands.
|
||||
///
|
||||
/// Preference order is the bundled Node path, then a machine Node install,
|
||||
/// then Electron from the machine or a Codex desktop app bundle.
|
||||
pub fn resolve_js_runtime(&self) -> Result<JsRuntime, ArtifactRuntimeError> {
|
||||
resolve_js_runtime_from_candidates(
|
||||
Some(self.node_path()),
|
||||
system_node_runtime(),
|
||||
system_electron_runtime(),
|
||||
codex_app_runtime_candidates(),
|
||||
)
|
||||
.ok_or_else(|| ArtifactRuntimeError::MissingJsRuntime {
|
||||
root_dir: self.root_dir.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cached_runtime_install_dir(
|
||||
cache_root: &Path,
|
||||
runtime_version: &str,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> PathBuf {
|
||||
cache_root.join(runtime_version).join(platform.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn default_cached_runtime_root(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(super::DEFAULT_CACHE_ROOT_RELATIVE)
|
||||
}
|
||||
|
||||
fn resolve_relative_runtime_path(
|
||||
root_dir: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<PathBuf, ArtifactRuntimeError> {
|
||||
let relative = Path::new(relative_path);
|
||||
if relative.as_os_str().is_empty() || relative.is_absolute() {
|
||||
return Err(ArtifactRuntimeError::InvalidRuntimePath(
|
||||
relative_path.to_string(),
|
||||
));
|
||||
}
|
||||
if relative.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::Prefix(_) | Component::RootDir
|
||||
)
|
||||
}) {
|
||||
return Err(ArtifactRuntimeError::InvalidRuntimePath(
|
||||
relative_path.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(root_dir.join(relative))
|
||||
}
|
||||
|
||||
fn verify_required_runtime_path(path: &Path) -> Result<(), ArtifactRuntimeError> {
|
||||
if path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ArtifactRuntimeError::Io {
|
||||
context: format!("required runtime file is missing: {}", path.display()),
|
||||
source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing runtime file"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use crate::ArtifactRuntimePlatform;
|
||||
use crate::runtime::default_cached_runtime_root;
|
||||
use crate::runtime::load_cached_runtime;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use which::which;
|
||||
|
||||
const CODEX_APP_PRODUCT_NAMES: [&str; 6] = [
|
||||
"Codex",
|
||||
"Codex (Dev)",
|
||||
"Codex (Agent)",
|
||||
"Codex (Nightly)",
|
||||
"Codex (Alpha)",
|
||||
"Codex (Beta)",
|
||||
];
|
||||
|
||||
/// The JavaScript runtime used to execute the artifact tool.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum JsRuntimeKind {
|
||||
Node,
|
||||
Electron,
|
||||
}
|
||||
|
||||
/// A discovered JavaScript executable and the way it should be invoked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct JsRuntime {
|
||||
executable_path: PathBuf,
|
||||
kind: JsRuntimeKind,
|
||||
}
|
||||
|
||||
impl JsRuntime {
|
||||
pub(crate) fn node(executable_path: PathBuf) -> Self {
|
||||
Self {
|
||||
executable_path,
|
||||
kind: JsRuntimeKind::Node,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn electron(executable_path: PathBuf) -> Self {
|
||||
Self {
|
||||
executable_path,
|
||||
kind: JsRuntimeKind::Electron,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the executable to spawn for artifact commands.
|
||||
pub fn executable_path(&self) -> &Path {
|
||||
&self.executable_path
|
||||
}
|
||||
|
||||
/// Returns whether the command must set `ELECTRON_RUN_AS_NODE=1`.
|
||||
pub fn requires_electron_run_as_node(&self) -> bool {
|
||||
self.kind == JsRuntimeKind::Electron
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when artifact execution can find both runtime assets and a JS executable.
|
||||
pub fn is_js_runtime_available(codex_home: &Path, runtime_version: &str) -> bool {
|
||||
load_cached_runtime(&default_cached_runtime_root(codex_home), runtime_version)
|
||||
.ok()
|
||||
.and_then(|runtime| runtime.resolve_js_runtime().ok())
|
||||
.or_else(resolve_machine_js_runtime)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Returns `true` when this machine can use the managed artifact runtime flow.
|
||||
///
|
||||
/// This is a platform capability check, not a cache or binary availability check.
|
||||
/// Callers that rely on `ArtifactRuntimeManager::ensure_installed()` should use this
|
||||
/// to decide whether the feature can be exposed on the current machine.
|
||||
pub fn can_manage_artifact_runtime() -> bool {
|
||||
ArtifactRuntimePlatform::detect_current().is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_machine_js_runtime() -> Option<JsRuntime> {
|
||||
resolve_js_runtime_from_candidates(
|
||||
None,
|
||||
system_node_runtime(),
|
||||
system_electron_runtime(),
|
||||
codex_app_runtime_candidates(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_js_runtime_from_candidates(
|
||||
preferred_node_path: Option<&Path>,
|
||||
node_runtime: Option<JsRuntime>,
|
||||
electron_runtime: Option<JsRuntime>,
|
||||
codex_app_candidates: Vec<PathBuf>,
|
||||
) -> Option<JsRuntime> {
|
||||
preferred_node_path
|
||||
.and_then(node_runtime_from_path)
|
||||
.or(node_runtime)
|
||||
.or(electron_runtime)
|
||||
.or_else(|| {
|
||||
codex_app_candidates
|
||||
.into_iter()
|
||||
.find_map(|candidate| electron_runtime_from_path(&candidate))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn system_node_runtime() -> Option<JsRuntime> {
|
||||
which("node")
|
||||
.ok()
|
||||
.and_then(|path| node_runtime_from_path(&path))
|
||||
}
|
||||
|
||||
pub(crate) fn system_electron_runtime() -> Option<JsRuntime> {
|
||||
which("electron")
|
||||
.ok()
|
||||
.and_then(|path| electron_runtime_from_path(&path))
|
||||
}
|
||||
|
||||
pub(crate) fn node_runtime_from_path(path: &Path) -> Option<JsRuntime> {
|
||||
path.is_file().then(|| JsRuntime::node(path.to_path_buf()))
|
||||
}
|
||||
|
||||
pub(crate) fn electron_runtime_from_path(path: &Path) -> Option<JsRuntime> {
|
||||
path.is_file()
|
||||
.then(|| JsRuntime::electron(path.to_path_buf()))
|
||||
}
|
||||
|
||||
pub(crate) fn codex_app_runtime_candidates() -> Vec<PathBuf> {
|
||||
match std::env::consts::OS {
|
||||
"macos" => {
|
||||
let mut roots = vec![PathBuf::from("/Applications")];
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
roots.push(PathBuf::from(home).join("Applications"));
|
||||
}
|
||||
|
||||
roots
|
||||
.into_iter()
|
||||
.flat_map(|root| {
|
||||
CODEX_APP_PRODUCT_NAMES
|
||||
.into_iter()
|
||||
.map(move |product_name| {
|
||||
root.join(format!("{product_name}.app"))
|
||||
.join("Contents")
|
||||
.join("MacOS")
|
||||
.join(product_name)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"windows" => {
|
||||
let mut roots = Vec::new();
|
||||
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
|
||||
roots.push(PathBuf::from(local_app_data).join("Programs"));
|
||||
}
|
||||
if let Some(program_files) = std::env::var_os("ProgramFiles") {
|
||||
roots.push(PathBuf::from(program_files));
|
||||
}
|
||||
if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") {
|
||||
roots.push(PathBuf::from(program_files_x86));
|
||||
}
|
||||
|
||||
roots
|
||||
.into_iter()
|
||||
.flat_map(|root| {
|
||||
CODEX_APP_PRODUCT_NAMES
|
||||
.into_iter()
|
||||
.map(move |product_name| {
|
||||
root.join(product_name).join(format!("{product_name}.exe"))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"linux" => [PathBuf::from("/opt"), PathBuf::from("/usr/lib")]
|
||||
.into_iter()
|
||||
.flat_map(|root| {
|
||||
CODEX_APP_PRODUCT_NAMES
|
||||
.into_iter()
|
||||
.map(move |product_name| root.join(product_name).join(product_name))
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
use super::ArtifactRuntimeError;
|
||||
use super::ArtifactRuntimePlatform;
|
||||
use super::InstalledArtifactRuntime;
|
||||
use super::ReleaseManifest;
|
||||
use codex_package_manager::ManagedPackage;
|
||||
use codex_package_manager::PackageManager;
|
||||
use codex_package_manager::PackageManagerConfig;
|
||||
use codex_package_manager::PackageManagerError;
|
||||
use codex_package_manager::PackageReleaseArchive;
|
||||
use reqwest::Client;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
|
||||
/// Release tag prefix used for artifact runtime assets.
|
||||
pub const DEFAULT_RELEASE_TAG_PREFIX: &str = "artifact-runtime-v";
|
||||
|
||||
/// Relative cache root for installed artifact runtimes under `codex_home`.
|
||||
pub const DEFAULT_CACHE_ROOT_RELATIVE: &str = "packages/artifacts";
|
||||
|
||||
/// Base URL used by default when downloading runtime assets from GitHub releases.
|
||||
pub const DEFAULT_RELEASE_BASE_URL: &str = "https://github.com/openai/codex/releases/download/";
|
||||
|
||||
/// Describes where a particular artifact runtime release can be downloaded from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactRuntimeReleaseLocator {
|
||||
base_url: Url,
|
||||
runtime_version: String,
|
||||
release_tag_prefix: String,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeReleaseLocator {
|
||||
/// Creates a locator for a runtime version under a release base URL.
|
||||
pub fn new(base_url: Url, runtime_version: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
runtime_version: runtime_version.into(),
|
||||
release_tag_prefix: DEFAULT_RELEASE_TAG_PREFIX.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the release-tag prefix used when constructing asset names.
|
||||
pub fn with_tag_prefix(mut self, release_tag_prefix: impl Into<String>) -> Self {
|
||||
self.release_tag_prefix = release_tag_prefix.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the release asset base URL.
|
||||
pub fn base_url(&self) -> &Url {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
/// Returns the expected runtime version.
|
||||
pub fn runtime_version(&self) -> &str {
|
||||
&self.runtime_version
|
||||
}
|
||||
|
||||
/// Returns the full release tag for the runtime version.
|
||||
pub fn release_tag(&self) -> String {
|
||||
format!("{}{}", self.release_tag_prefix, self.runtime_version)
|
||||
}
|
||||
|
||||
/// Returns the expected manifest filename for the release.
|
||||
pub fn manifest_file_name(&self) -> String {
|
||||
format!("{}-manifest.json", self.release_tag())
|
||||
}
|
||||
|
||||
/// Returns the manifest URL for this runtime release.
|
||||
pub fn manifest_url(&self) -> Result<Url, PackageManagerError> {
|
||||
self.base_url
|
||||
.join(&format!(
|
||||
"{}/{}",
|
||||
self.release_tag(),
|
||||
self.manifest_file_name()
|
||||
))
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
|
||||
/// Returns the default GitHub-release locator for a runtime version.
|
||||
pub fn default(runtime_version: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
match Url::parse(DEFAULT_RELEASE_BASE_URL) {
|
||||
Ok(url) => url,
|
||||
Err(error) => {
|
||||
panic!("hard-coded artifact runtime release base URL must be valid: {error}")
|
||||
}
|
||||
},
|
||||
runtime_version,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for resolving artifact runtimes under a Codex home directory.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactRuntimeManagerConfig {
|
||||
package_manager: PackageManagerConfig<ArtifactRuntimePackage>,
|
||||
release: ArtifactRuntimeReleaseLocator,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeManagerConfig {
|
||||
/// Creates a runtime-manager config from a Codex home and explicit release locator.
|
||||
pub fn new(codex_home: PathBuf, release: ArtifactRuntimeReleaseLocator) -> Self {
|
||||
Self {
|
||||
package_manager: PackageManagerConfig::new(
|
||||
codex_home,
|
||||
ArtifactRuntimePackage::new(release.clone()),
|
||||
),
|
||||
release,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a runtime-manager config that downloads from the default GitHub release location.
|
||||
pub fn with_default_release(codex_home: PathBuf, runtime_version: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
codex_home,
|
||||
ArtifactRuntimeReleaseLocator::default(runtime_version),
|
||||
)
|
||||
}
|
||||
|
||||
/// Overrides the runtime cache root.
|
||||
pub fn with_cache_root(mut self, cache_root: PathBuf) -> Self {
|
||||
self.package_manager = self.package_manager.with_cache_root(cache_root);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the runtime cache root.
|
||||
pub fn cache_root(&self) -> PathBuf {
|
||||
self.package_manager.cache_root()
|
||||
}
|
||||
|
||||
/// Returns the release locator used by this config.
|
||||
pub fn release(&self) -> &ArtifactRuntimeReleaseLocator {
|
||||
&self.release
|
||||
}
|
||||
}
|
||||
|
||||
/// Package-manager-backed artifact runtime resolver and installer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtifactRuntimeManager {
|
||||
package_manager: PackageManager<ArtifactRuntimePackage>,
|
||||
config: ArtifactRuntimeManagerConfig,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeManager {
|
||||
/// Creates a runtime manager using the default `reqwest` client.
|
||||
pub fn new(config: ArtifactRuntimeManagerConfig) -> Self {
|
||||
let package_manager = PackageManager::new(config.package_manager.clone());
|
||||
Self {
|
||||
package_manager,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a runtime manager with a caller-provided HTTP client.
|
||||
pub fn with_client(config: ArtifactRuntimeManagerConfig, client: Client) -> Self {
|
||||
let package_manager = PackageManager::with_client(config.package_manager.clone(), client);
|
||||
Self {
|
||||
package_manager,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the manager configuration.
|
||||
pub fn config(&self) -> &ArtifactRuntimeManagerConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Returns the installed runtime if it is already present and valid.
|
||||
pub async fn resolve_cached(
|
||||
&self,
|
||||
) -> Result<Option<InstalledArtifactRuntime>, ArtifactRuntimeError> {
|
||||
self.package_manager.resolve_cached().await
|
||||
}
|
||||
|
||||
/// Returns the installed runtime, downloading and caching it if necessary.
|
||||
pub async fn ensure_installed(&self) -> Result<InstalledArtifactRuntime, ArtifactRuntimeError> {
|
||||
self.package_manager.ensure_installed().await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ArtifactRuntimePackage {
|
||||
release: ArtifactRuntimeReleaseLocator,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimePackage {
|
||||
fn new(release: ArtifactRuntimeReleaseLocator) -> Self {
|
||||
Self { release }
|
||||
}
|
||||
}
|
||||
|
||||
impl ManagedPackage for ArtifactRuntimePackage {
|
||||
type Error = ArtifactRuntimeError;
|
||||
type Installed = InstalledArtifactRuntime;
|
||||
type ReleaseManifest = ReleaseManifest;
|
||||
|
||||
fn default_cache_root_relative(&self) -> &str {
|
||||
DEFAULT_CACHE_ROOT_RELATIVE
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
self.release.runtime_version()
|
||||
}
|
||||
|
||||
fn manifest_url(&self) -> Result<Url, PackageManagerError> {
|
||||
self.release.manifest_url()
|
||||
}
|
||||
|
||||
fn archive_url(&self, archive: &PackageReleaseArchive) -> Result<Url, PackageManagerError> {
|
||||
self.release
|
||||
.base_url()
|
||||
.join(&format!(
|
||||
"{}/{}",
|
||||
self.release.release_tag(),
|
||||
archive.archive
|
||||
))
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
|
||||
fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str {
|
||||
&manifest.runtime_version
|
||||
}
|
||||
|
||||
fn platform_archive(
|
||||
&self,
|
||||
manifest: &Self::ReleaseManifest,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<PackageReleaseArchive, Self::Error> {
|
||||
manifest
|
||||
.platforms
|
||||
.get(platform.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
PackageManagerError::MissingPlatform(platform.as_str().to_string()).into()
|
||||
})
|
||||
}
|
||||
|
||||
fn install_dir(&self, cache_root: &Path, platform: ArtifactRuntimePlatform) -> PathBuf {
|
||||
cache_root.join(self.version()).join(platform.as_str())
|
||||
}
|
||||
|
||||
fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str {
|
||||
package.runtime_version()
|
||||
}
|
||||
|
||||
fn load_installed(
|
||||
&self,
|
||||
root_dir: PathBuf,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<Self::Installed, Self::Error> {
|
||||
InstalledArtifactRuntime::load(root_dir, platform)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use codex_package_manager::PackageReleaseArchive;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Release metadata published alongside the packaged artifact runtime.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ReleaseManifest {
|
||||
pub schema_version: u32,
|
||||
pub runtime_version: String,
|
||||
pub release_tag: String,
|
||||
#[serde(default)]
|
||||
pub node_version: Option<String>,
|
||||
pub platforms: BTreeMap<String, PackageReleaseArchive>,
|
||||
}
|
||||
|
||||
/// Manifest shipped inside the extracted runtime payload.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ExtractedRuntimeManifest {
|
||||
pub schema_version: u32,
|
||||
pub runtime_version: String,
|
||||
pub node: RuntimePathEntry,
|
||||
pub entrypoints: RuntimeEntrypoints,
|
||||
}
|
||||
|
||||
/// A relative path entry inside an extracted runtime manifest.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct RuntimePathEntry {
|
||||
pub relative_path: String,
|
||||
}
|
||||
|
||||
/// Entrypoints required to build and render artifacts.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct RuntimeEntrypoints {
|
||||
pub build_js: RuntimePathEntry,
|
||||
pub render_cli: RuntimePathEntry,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
mod error;
|
||||
mod installed;
|
||||
mod js_runtime;
|
||||
mod manager;
|
||||
mod manifest;
|
||||
|
||||
pub use codex_package_manager::PackagePlatform as ArtifactRuntimePlatform;
|
||||
pub use error::ArtifactRuntimeError;
|
||||
pub use installed::InstalledArtifactRuntime;
|
||||
pub use installed::load_cached_runtime;
|
||||
pub use js_runtime::JsRuntime;
|
||||
pub use js_runtime::JsRuntimeKind;
|
||||
pub use js_runtime::can_manage_artifact_runtime;
|
||||
pub use js_runtime::is_js_runtime_available;
|
||||
pub use manager::ArtifactRuntimeManager;
|
||||
pub use manager::ArtifactRuntimeManagerConfig;
|
||||
pub use manager::ArtifactRuntimeReleaseLocator;
|
||||
pub use manager::DEFAULT_CACHE_ROOT_RELATIVE;
|
||||
pub use manager::DEFAULT_RELEASE_BASE_URL;
|
||||
pub use manager::DEFAULT_RELEASE_TAG_PREFIX;
|
||||
pub use manifest::ExtractedRuntimeManifest;
|
||||
pub use manifest::ReleaseManifest;
|
||||
pub use manifest::RuntimeEntrypoints;
|
||||
pub use manifest::RuntimePathEntry;
|
||||
|
||||
pub(crate) use installed::default_cached_runtime_root;
|
||||
pub(crate) use js_runtime::codex_app_runtime_candidates;
|
||||
pub(crate) use js_runtime::resolve_js_runtime_from_candidates;
|
||||
pub(crate) use js_runtime::system_electron_runtime;
|
||||
pub(crate) use js_runtime::system_node_runtime;
|
||||
@@ -0,0 +1,521 @@
|
||||
use crate::ArtifactBuildRequest;
|
||||
use crate::ArtifactCommandOutput;
|
||||
use crate::ArtifactRenderCommandRequest;
|
||||
use crate::ArtifactRenderTarget;
|
||||
use crate::ArtifactRuntimeManager;
|
||||
use crate::ArtifactRuntimeManagerConfig;
|
||||
use crate::ArtifactRuntimePlatform;
|
||||
use crate::ArtifactRuntimeReleaseLocator;
|
||||
use crate::ArtifactsClient;
|
||||
use crate::DEFAULT_CACHE_ROOT_RELATIVE;
|
||||
use crate::ExtractedRuntimeManifest;
|
||||
use crate::InstalledArtifactRuntime;
|
||||
use crate::JsRuntime;
|
||||
use crate::PresentationRenderTarget;
|
||||
use crate::ReleaseManifest;
|
||||
use crate::RuntimeEntrypoints;
|
||||
use crate::RuntimePathEntry;
|
||||
use crate::SpreadsheetRenderTarget;
|
||||
use crate::load_cached_runtime;
|
||||
use codex_package_manager::ArchiveFormat;
|
||||
use codex_package_manager::PackageReleaseArchive;
|
||||
use pretty_assertions::assert_eq;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
#[test]
|
||||
fn release_locator_builds_manifest_url() {
|
||||
let locator = ArtifactRuntimeReleaseLocator::new(
|
||||
url::Url::parse("https://example.test/releases/").unwrap_or_else(|error| panic!("{error}")),
|
||||
"0.1.0",
|
||||
);
|
||||
let url = locator
|
||||
.manifest_url()
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://example.test/releases/artifact-runtime-v0.1.0/artifact-runtime-v0.1.0-manifest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_release_locator_uses_openai_codex_github_releases() {
|
||||
let locator = ArtifactRuntimeReleaseLocator::default("0.1.0");
|
||||
let url = locator
|
||||
.manifest_url()
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://github.com/openai/codex/releases/download/artifact-runtime-v0.1.0/artifact-runtime-v0.1.0-manifest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cached_runtime_reads_installed_runtime() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let runtime_version = "0.1.0";
|
||||
let platform =
|
||||
ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let install_dir = codex_home
|
||||
.path()
|
||||
.join(DEFAULT_CACHE_ROOT_RELATIVE)
|
||||
.join(runtime_version)
|
||||
.join(platform.as_str());
|
||||
write_installed_runtime(
|
||||
&install_dir,
|
||||
runtime_version,
|
||||
Some(PathBuf::from("node/bin/node")),
|
||||
);
|
||||
|
||||
let runtime = load_cached_runtime(
|
||||
&codex_home.path().join(DEFAULT_CACHE_ROOT_RELATIVE),
|
||||
runtime_version,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(runtime.runtime_version(), runtime_version);
|
||||
assert_eq!(runtime.platform(), platform);
|
||||
assert!(runtime.node_path().ends_with(Path::new("node/bin/node")));
|
||||
assert!(
|
||||
runtime
|
||||
.build_js_path()
|
||||
.ends_with(Path::new("artifact-tool/dist/artifact_tool.mjs"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cached_runtime_rejects_parent_relative_paths() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let runtime_version = "0.1.0";
|
||||
let platform =
|
||||
ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let install_dir = codex_home
|
||||
.path()
|
||||
.join(DEFAULT_CACHE_ROOT_RELATIVE)
|
||||
.join(runtime_version)
|
||||
.join(platform.as_str());
|
||||
write_installed_runtime(
|
||||
&install_dir,
|
||||
runtime_version,
|
||||
Some(PathBuf::from("../node/bin/node")),
|
||||
);
|
||||
|
||||
let error = load_cached_runtime(
|
||||
&codex_home.path().join(DEFAULT_CACHE_ROOT_RELATIVE),
|
||||
runtime_version,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"runtime path `../node/bin/node` is invalid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cached_runtime_requires_build_entrypoint() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let runtime_version = "0.1.0";
|
||||
let platform =
|
||||
ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let install_dir = codex_home
|
||||
.path()
|
||||
.join(DEFAULT_CACHE_ROOT_RELATIVE)
|
||||
.join(runtime_version)
|
||||
.join(platform.as_str());
|
||||
write_installed_runtime(
|
||||
&install_dir,
|
||||
runtime_version,
|
||||
Some(PathBuf::from("node/bin/node")),
|
||||
);
|
||||
fs::remove_file(install_dir.join("artifact-tool/dist/artifact_tool.mjs"))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
let error = load_cached_runtime(
|
||||
&codex_home.path().join(DEFAULT_CACHE_ROOT_RELATIVE),
|
||||
runtime_version,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"required runtime file is missing: {}",
|
||||
install_dir
|
||||
.join("artifact-tool/dist/artifact_tool.mjs")
|
||||
.display()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_downloads_and_extracts_zip_runtime() {
|
||||
let server = MockServer::start().await;
|
||||
let runtime_version = "0.1.0";
|
||||
let platform =
|
||||
ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!(
|
||||
"artifact-runtime-v{runtime_version}-{}.zip",
|
||||
platform.as_str()
|
||||
);
|
||||
let archive_bytes = build_zip_archive(runtime_version);
|
||||
let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes));
|
||||
let manifest = ReleaseManifest {
|
||||
schema_version: 1,
|
||||
runtime_version: runtime_version.to_string(),
|
||||
release_tag: format!("artifact-runtime-v{runtime_version}"),
|
||||
node_version: Some("22.0.0".to_string()),
|
||||
platforms: BTreeMap::from([(
|
||||
platform.as_str().to_string(),
|
||||
PackageReleaseArchive {
|
||||
archive: archive_name.clone(),
|
||||
sha256: archive_sha,
|
||||
format: ArchiveFormat::Zip,
|
||||
size_bytes: Some(archive_bytes.len() as u64),
|
||||
},
|
||||
)]),
|
||||
};
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!(
|
||||
"/artifact-runtime-v{runtime_version}/artifact-runtime-v{runtime_version}-manifest.json"
|
||||
)))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!(
|
||||
"/artifact-runtime-v{runtime_version}/{archive_name}"
|
||||
)))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let locator = ArtifactRuntimeReleaseLocator::new(
|
||||
url::Url::parse(&format!("{}/", server.uri())).unwrap_or_else(|error| panic!("{error}")),
|
||||
runtime_version,
|
||||
);
|
||||
let manager = ArtifactRuntimeManager::new(ArtifactRuntimeManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
locator,
|
||||
));
|
||||
|
||||
let runtime = manager
|
||||
.ensure_installed()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(runtime.runtime_version(), runtime_version);
|
||||
assert_eq!(runtime.platform(), platform);
|
||||
assert!(runtime.node_path().ends_with(Path::new("node/bin/node")));
|
||||
assert_eq!(
|
||||
runtime.resolve_js_runtime().expect("resolve js runtime"),
|
||||
JsRuntime::node(runtime.node_path().to_path_buf())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cached_runtime_uses_custom_cache_root() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let runtime_version = "0.1.0";
|
||||
let custom_cache_root = codex_home.path().join("runtime-cache");
|
||||
let platform =
|
||||
ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let install_dir = custom_cache_root
|
||||
.join(runtime_version)
|
||||
.join(platform.as_str());
|
||||
write_installed_runtime(
|
||||
&install_dir,
|
||||
runtime_version,
|
||||
Some(PathBuf::from("node/bin/node")),
|
||||
);
|
||||
|
||||
let config = ArtifactRuntimeManagerConfig::with_default_release(
|
||||
codex_home.path().to_path_buf(),
|
||||
runtime_version,
|
||||
)
|
||||
.with_cache_root(custom_cache_root);
|
||||
|
||||
let runtime = load_cached_runtime(&config.cache_root(), runtime_version)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(runtime.runtime_version(), runtime_version);
|
||||
assert_eq!(runtime.platform(), platform);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn artifacts_client_execute_build_writes_wrapped_script_and_env() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let output_path = temp.path().join("build-output.txt");
|
||||
let wrapped_script_path = temp.path().join("wrapped-script.mjs");
|
||||
let runtime = fake_installed_runtime(temp.path(), &output_path, &wrapped_script_path);
|
||||
let client = ArtifactsClient::from_installed_runtime(runtime);
|
||||
|
||||
let output = client
|
||||
.execute_build(ArtifactBuildRequest {
|
||||
source: "console.log('hello');".to_string(),
|
||||
cwd: temp.path().to_path_buf(),
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
env: BTreeMap::from([
|
||||
(
|
||||
"CODEX_TEST_OUTPUT".to_string(),
|
||||
output_path.display().to_string(),
|
||||
),
|
||||
("CUSTOM_ENV".to_string(), "custom-value".to_string()),
|
||||
]),
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_success(&output);
|
||||
let command_log = fs::read_to_string(&output_path).unwrap_or_else(|error| panic!("{error}"));
|
||||
assert!(command_log.contains("arg0="));
|
||||
assert!(command_log.contains("CODEX_ARTIFACT_BUILD_ENTRYPOINT="));
|
||||
assert!(command_log.contains("CODEX_ARTIFACT_RENDER_ENTRYPOINT="));
|
||||
assert!(command_log.contains("CUSTOM_ENV=custom-value"));
|
||||
|
||||
let wrapped_script =
|
||||
fs::read_to_string(wrapped_script_path).unwrap_or_else(|error| panic!("{error}"));
|
||||
assert!(wrapped_script.contains("globalThis.artifacts = artifactTool;"));
|
||||
assert!(wrapped_script.contains("globalThis.codexArtifacts = artifactTool;"));
|
||||
assert!(wrapped_script.contains("console.log('hello');"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn artifacts_client_execute_render_passes_expected_args() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let output_path = temp.path().join("render-output.txt");
|
||||
let wrapped_script_path = temp.path().join("unused-script-copy.mjs");
|
||||
let runtime = fake_installed_runtime(temp.path(), &output_path, &wrapped_script_path);
|
||||
let client = ArtifactsClient::from_installed_runtime(runtime.clone());
|
||||
let render_output = temp.path().join("slide.png");
|
||||
|
||||
let output = client
|
||||
.execute_render(ArtifactRenderCommandRequest {
|
||||
cwd: temp.path().to_path_buf(),
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
env: BTreeMap::from([(
|
||||
"CODEX_TEST_OUTPUT".to_string(),
|
||||
output_path.display().to_string(),
|
||||
)]),
|
||||
target: ArtifactRenderTarget::Presentation(PresentationRenderTarget {
|
||||
input_path: temp.path().join("deck.pptx"),
|
||||
output_path: render_output.clone(),
|
||||
slide_number: 3,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_success(&output);
|
||||
let command_log = fs::read_to_string(&output_path).unwrap_or_else(|error| panic!("{error}"));
|
||||
assert!(command_log.contains(&format!("arg0={}", runtime.render_cli_path().display())));
|
||||
assert!(command_log.contains("arg1=pptx"));
|
||||
assert!(command_log.contains("arg2=render"));
|
||||
assert!(command_log.contains("arg5=--slide"));
|
||||
assert!(command_log.contains("arg6=3"));
|
||||
assert!(command_log.contains("arg7=--out"));
|
||||
assert!(command_log.contains(&format!("arg8={}", render_output.display())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreadsheet_render_target_to_args_includes_optional_range() {
|
||||
let target = ArtifactRenderTarget::Spreadsheet(SpreadsheetRenderTarget {
|
||||
input_path: PathBuf::from("/tmp/input.xlsx"),
|
||||
output_path: PathBuf::from("/tmp/output.png"),
|
||||
sheet_name: "Summary".to_string(),
|
||||
range: Some("A1:C8".to_string()),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
target.to_args(),
|
||||
vec![
|
||||
"xlsx".to_string(),
|
||||
"render".to_string(),
|
||||
"--in".to_string(),
|
||||
"/tmp/input.xlsx".to_string(),
|
||||
"--sheet".to_string(),
|
||||
"Summary".to_string(),
|
||||
"--out".to_string(),
|
||||
"/tmp/output.png".to_string(),
|
||||
"--range".to_string(),
|
||||
"A1:C8".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_success(output: &ArtifactCommandOutput) {
|
||||
assert!(output.success());
|
||||
assert_eq!(output.exit_code, Some(0));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn fake_installed_runtime(
|
||||
root: &Path,
|
||||
output_path: &Path,
|
||||
wrapped_script_path: &Path,
|
||||
) -> InstalledArtifactRuntime {
|
||||
let runtime_root = root.join("runtime");
|
||||
write_installed_runtime(&runtime_root, "0.1.0", Some(PathBuf::from("node/bin/node")));
|
||||
write_fake_node_script(
|
||||
&runtime_root.join("node/bin/node"),
|
||||
output_path,
|
||||
wrapped_script_path,
|
||||
);
|
||||
InstalledArtifactRuntime::load(
|
||||
runtime_root,
|
||||
ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"))
|
||||
}
|
||||
|
||||
fn write_installed_runtime(
|
||||
install_dir: &Path,
|
||||
runtime_version: &str,
|
||||
node_relative: Option<PathBuf>,
|
||||
) {
|
||||
fs::create_dir_all(install_dir.join("node/bin")).unwrap_or_else(|error| panic!("{error}"));
|
||||
fs::create_dir_all(install_dir.join("artifact-tool/dist"))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
fs::create_dir_all(install_dir.join("granola-render/dist"))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
let node_relative = node_relative.unwrap_or_else(|| PathBuf::from("node/bin/node"));
|
||||
fs::write(
|
||||
install_dir.join("manifest.json"),
|
||||
serde_json::json!(sample_extracted_manifest(runtime_version, node_relative)).to_string(),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
fs::write(install_dir.join("node/bin/node"), "#!/bin/sh\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
fs::write(
|
||||
install_dir.join("artifact-tool/dist/artifact_tool.mjs"),
|
||||
"export const ok = true;\n",
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
fs::write(
|
||||
install_dir.join("granola-render/dist/render_cli.mjs"),
|
||||
"export const ok = true;\n",
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_fake_node_script(script_path: &Path, output_path: &Path, wrapped_script_path: &Path) {
|
||||
fs::write(
|
||||
script_path,
|
||||
format!(
|
||||
concat!(
|
||||
"#!/bin/sh\n",
|
||||
"printf 'arg0=%s\\n' \"$1\" > \"{}\"\n",
|
||||
"cp \"$1\" \"{}\"\n",
|
||||
"shift\n",
|
||||
"i=1\n",
|
||||
"for arg in \"$@\"; do\n",
|
||||
" printf 'arg%s=%s\\n' \"$i\" \"$arg\" >> \"{}\"\n",
|
||||
" i=$((i + 1))\n",
|
||||
"done\n",
|
||||
"printf 'CODEX_ARTIFACT_BUILD_ENTRYPOINT=%s\\n' \"$CODEX_ARTIFACT_BUILD_ENTRYPOINT\" >> \"{}\"\n",
|
||||
"printf 'CODEX_ARTIFACT_RENDER_ENTRYPOINT=%s\\n' \"$CODEX_ARTIFACT_RENDER_ENTRYPOINT\" >> \"{}\"\n",
|
||||
"printf 'CUSTOM_ENV=%s\\n' \"$CUSTOM_ENV\" >> \"{}\"\n",
|
||||
"echo stdout-ok\n",
|
||||
"echo stderr-ok >&2\n"
|
||||
),
|
||||
output_path.display(),
|
||||
wrapped_script_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut permissions = fs::metadata(script_path)
|
||||
.unwrap_or_else(|error| panic!("{error}"))
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(script_path, permissions).unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn build_zip_archive(runtime_version: &str) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut zip = ZipWriter::new(&mut bytes);
|
||||
let options = SimpleFileOptions::default();
|
||||
let manifest = serde_json::to_vec(&sample_extracted_manifest(
|
||||
runtime_version,
|
||||
PathBuf::from("node/bin/node"),
|
||||
))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("artifact-runtime/manifest.json", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(&manifest)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file(
|
||||
"artifact-runtime/node/bin/node",
|
||||
options.unix_permissions(0o755),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"#!/bin/sh\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file(
|
||||
"artifact-runtime/artifact-tool/dist/artifact_tool.mjs",
|
||||
options,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"export const ok = true;\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file(
|
||||
"artifact-runtime/granola-render/dist/render_cli.mjs",
|
||||
options,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"export const ok = true;\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
fn sample_extracted_manifest(
|
||||
runtime_version: &str,
|
||||
node_relative: PathBuf,
|
||||
) -> ExtractedRuntimeManifest {
|
||||
ExtractedRuntimeManifest {
|
||||
schema_version: 1,
|
||||
runtime_version: runtime_version.to_string(),
|
||||
node: RuntimePathEntry {
|
||||
relative_path: node_relative.display().to_string(),
|
||||
},
|
||||
entrypoints: RuntimeEntrypoints {
|
||||
build_js: RuntimePathEntry {
|
||||
relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(),
|
||||
},
|
||||
render_cli: RuntimePathEntry {
|
||||
relative_path: "granola-render/dist/render_cli.mjs".to_string(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -446,6 +446,7 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +484,7 @@ mod tests {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +542,7 @@ mod tests {
|
||||
install_url: Some(connector_install_url(id, id)),
|
||||
is_accessible,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+131
-30
@@ -64,6 +64,7 @@ use codex_hooks::HooksConfig;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
use codex_network_proxy::normalize_host;
|
||||
use codex_otel::current_span_trace_id;
|
||||
use codex_otel::current_span_w3c_trace_context;
|
||||
use codex_otel::set_parent_from_w3c_trace_context;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -202,9 +203,11 @@ use crate::memories;
|
||||
use crate::mentions::build_connector_slug_counts;
|
||||
use crate::mentions::build_skill_name_counts;
|
||||
use crate::mentions::collect_explicit_app_ids;
|
||||
use crate::mentions::collect_explicit_plugin_mentions;
|
||||
use crate::mentions::collect_tool_mentions_from_messages;
|
||||
use crate::network_policy_decision::execpolicy_network_rule_amendment;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::plugins::build_plugin_injections;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
use crate::protocol::AgentMessageContentDeltaEvent;
|
||||
use crate::protocol::AgentReasoningSectionBreakEvent;
|
||||
@@ -511,7 +514,7 @@ impl Codex {
|
||||
session_source_clone,
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
mcp_manager.clone(),
|
||||
file_watcher,
|
||||
agent_control,
|
||||
)
|
||||
@@ -652,6 +655,7 @@ impl TurnSkillsContext {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TurnContext {
|
||||
pub(crate) sub_id: String,
|
||||
pub(crate) trace_id: Option<String>,
|
||||
pub(crate) realtime_active: bool,
|
||||
pub(crate) config: Arc<Config>,
|
||||
pub(crate) auth_manager: Option<Arc<AuthManager>>,
|
||||
@@ -740,6 +744,7 @@ impl TurnContext {
|
||||
|
||||
Self {
|
||||
sub_id: self.sub_id.clone(),
|
||||
trace_id: self.trace_id.clone(),
|
||||
realtime_active: self.realtime_active,
|
||||
config: Arc::new(config),
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
@@ -795,6 +800,7 @@ impl TurnContext {
|
||||
pub(crate) fn to_turn_context_item(&self) -> TurnContextItem {
|
||||
TurnContextItem {
|
||||
turn_id: Some(self.sub_id.clone()),
|
||||
trace_id: self.trace_id.clone(),
|
||||
cwd: self.cwd.clone(),
|
||||
current_date: self.current_date.clone(),
|
||||
timezone: self.timezone.clone(),
|
||||
@@ -1124,6 +1130,7 @@ impl Session {
|
||||
let (current_date, timezone) = local_time_context();
|
||||
TurnContext {
|
||||
sub_id,
|
||||
trace_id: current_span_trace_id(),
|
||||
realtime_active: false,
|
||||
config: per_turn_config.clone(),
|
||||
auth_manager: auth_manager_for_context,
|
||||
@@ -1525,8 +1532,8 @@ impl Session {
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
execve_session_approvals: RwLock::new(HashMap::new()),
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
plugins_manager: Arc::clone(&plugins_manager),
|
||||
mcp_manager: Arc::clone(&mcp_manager),
|
||||
file_watcher,
|
||||
agent_control,
|
||||
network_proxy,
|
||||
@@ -1610,6 +1617,7 @@ impl Session {
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
required_mcp_servers.sort();
|
||||
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config.as_ref());
|
||||
{
|
||||
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
|
||||
cancel_guard.cancel();
|
||||
@@ -1624,6 +1632,7 @@ impl Session {
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth),
|
||||
tool_plugin_provenance,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
@@ -3569,6 +3578,10 @@ impl Session {
|
||||
) {
|
||||
let auth = self.services.auth_manager.auth().await;
|
||||
let config = self.get_config().await;
|
||||
let tool_plugin_provenance = self
|
||||
.services
|
||||
.mcp_manager
|
||||
.tool_plugin_provenance(config.as_ref());
|
||||
let mcp_servers = with_codex_apps_mcp(
|
||||
mcp_servers,
|
||||
self.features.enabled(Feature::Apps),
|
||||
@@ -3596,6 +3609,7 @@ impl Session {
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
tool_plugin_provenance,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
@@ -3869,8 +3883,16 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
server_name,
|
||||
request_id,
|
||||
decision,
|
||||
content,
|
||||
} => {
|
||||
handlers::resolve_elicitation(&sess, server_name, request_id, decision).await;
|
||||
handlers::resolve_elicitation(
|
||||
&sess,
|
||||
server_name,
|
||||
request_id,
|
||||
decision,
|
||||
content,
|
||||
)
|
||||
.await;
|
||||
false
|
||||
}
|
||||
Op::Shutdown => handlers::shutdown(&sess, sub.id.clone()).await,
|
||||
@@ -3958,6 +3980,7 @@ mod handlers {
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
@@ -4092,16 +4115,16 @@ mod handlers {
|
||||
server_name: String,
|
||||
request_id: ProtocolRequestId,
|
||||
decision: codex_protocol::approvals::ElicitationAction,
|
||||
content: Option<Value>,
|
||||
) {
|
||||
let action = match decision {
|
||||
codex_protocol::approvals::ElicitationAction::Accept => ElicitationAction::Accept,
|
||||
codex_protocol::approvals::ElicitationAction::Decline => ElicitationAction::Decline,
|
||||
codex_protocol::approvals::ElicitationAction::Cancel => ElicitationAction::Cancel,
|
||||
};
|
||||
// When accepting, send an empty object as content to satisfy MCP servers
|
||||
// that expect non-null content on Accept. For Decline/Cancel, content is None.
|
||||
let content = match action {
|
||||
ElicitationAction::Accept => Some(serde_json::json!({})),
|
||||
// Preserve the legacy fallback for clients that only send an action.
|
||||
ElicitationAction::Accept => Some(content.unwrap_or_else(|| serde_json::json!({}))),
|
||||
ElicitationAction::Decline | ElicitationAction::Cancel => None,
|
||||
};
|
||||
let response = ElicitationResponse { action, content };
|
||||
@@ -4764,6 +4787,7 @@ async fn spawn_review_thread(
|
||||
|
||||
let review_turn_context = TurnContext {
|
||||
sub_id: review_turn_id,
|
||||
trace_id: current_span_trace_id(),
|
||||
realtime_active: parent_turn_context.realtime_active,
|
||||
config: per_turn_config,
|
||||
auth_manager: auth_manager_for_context,
|
||||
@@ -4927,25 +4951,38 @@ pub(crate) async fn run_turn(
|
||||
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
|
||||
.await;
|
||||
|
||||
let available_connectors = if turn_context.config.features.enabled(Feature::Apps) {
|
||||
let mcp_tools = match sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.or_cancel(&cancellation_token)
|
||||
.await
|
||||
{
|
||||
Ok(mcp_tools) => mcp_tools,
|
||||
Err(_) => return None,
|
||||
let loaded_plugins = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config);
|
||||
// Plain-text @plugin mentions are resolved from the current session's
|
||||
// enabled plugins, then converted into turn-scoped guidance below.
|
||||
let mentioned_plugins =
|
||||
collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries());
|
||||
let mcp_tools =
|
||||
if turn_context.config.features.enabled(Feature::Apps) || !mentioned_plugins.is_empty() {
|
||||
// Plugin mentions need raw MCP/app inventory even when app tools
|
||||
// are normally hidden so we can describe the plugin's currently
|
||||
// usable capabilities for this turn.
|
||||
match sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.or_cancel(&cancellation_token)
|
||||
.await
|
||||
{
|
||||
Ok(mcp_tools) => mcp_tools,
|
||||
Err(_) if turn_context.config.features.enabled(Feature::Apps) => return None,
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let plugin_apps = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config);
|
||||
let available_connectors = if turn_context.config.features.enabled(Feature::Apps) {
|
||||
let connectors = connectors::merge_plugin_apps_with_accessible(
|
||||
plugin_apps.effective_apps(),
|
||||
loaded_plugins.effective_apps(),
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
);
|
||||
connectors::with_app_enabled_state(connectors, &turn_context.config)
|
||||
@@ -5006,12 +5043,29 @@ pub(crate) async fn run_turn(
|
||||
.await;
|
||||
}
|
||||
|
||||
let plugin_items =
|
||||
build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors);
|
||||
|
||||
let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input);
|
||||
explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items(
|
||||
&skill_items,
|
||||
&available_connectors,
|
||||
&skill_name_counts_lower,
|
||||
));
|
||||
// Explicit @plugin mentions can make a plugin's enabled apps callable for
|
||||
// this turn without persisting those connectors as sticky user selections.
|
||||
let mut turn_enabled_connectors = explicitly_enabled_connectors.clone();
|
||||
turn_enabled_connectors.extend(
|
||||
mentioned_plugins
|
||||
.iter()
|
||||
.flat_map(|plugin| plugin.app_connector_ids.iter())
|
||||
.map(|connector_id| connector_id.0.clone())
|
||||
.filter(|connector_id| {
|
||||
available_connectors
|
||||
.iter()
|
||||
.any(|connector| connector.is_enabled && connector.id == *connector_id)
|
||||
}),
|
||||
);
|
||||
let connector_names_by_id = available_connectors
|
||||
.iter()
|
||||
.map(|connector| (connector.id.as_str(), connector.name.as_str()))
|
||||
@@ -5049,6 +5103,10 @@ pub(crate) async fn run_turn(
|
||||
sess.record_conversation_items(&turn_context, &skill_items)
|
||||
.await;
|
||||
}
|
||||
if !plugin_items.is_empty() {
|
||||
sess.record_conversation_items(&turn_context, &plugin_items)
|
||||
.await;
|
||||
}
|
||||
|
||||
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
|
||||
.await;
|
||||
@@ -5117,7 +5175,7 @@ pub(crate) async fn run_turn(
|
||||
&mut client_session,
|
||||
turn_metadata_header.as_deref(),
|
||||
sampling_request_input,
|
||||
&explicitly_enabled_connectors,
|
||||
&turn_enabled_connectors,
|
||||
skills_outcome,
|
||||
&mut server_model_warning_emitted_for_turn,
|
||||
cancellation_token.child_token(),
|
||||
@@ -5663,17 +5721,17 @@ async fn built_tools(
|
||||
.or_cancel(cancellation_token)
|
||||
.await?;
|
||||
drop(mcp_connection_manager);
|
||||
let loaded_plugins = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config);
|
||||
|
||||
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
|
||||
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
|
||||
|
||||
let connectors = if turn_context.features.enabled(Feature::Apps) {
|
||||
let plugin_apps = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config);
|
||||
let connectors = connectors::merge_plugin_apps_with_accessible(
|
||||
plugin_apps.effective_apps(),
|
||||
loaded_plugins.effective_apps(),
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
);
|
||||
Some(connectors::with_app_enabled_state(
|
||||
@@ -5684,6 +5742,8 @@ async fn built_tools(
|
||||
None
|
||||
};
|
||||
|
||||
// Keep the connector-grouped app view around for the router even though
|
||||
// app tools only become prompt-visible after explicit selection/discovery.
|
||||
let app_tools = connectors.as_ref().map(|connectors| {
|
||||
filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config)
|
||||
});
|
||||
@@ -6643,6 +6703,7 @@ mod tests {
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use tracing::Span;
|
||||
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::CreditsSnapshot;
|
||||
@@ -6763,6 +6824,7 @@ mod tests {
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6850,6 +6912,7 @@ mod tests {
|
||||
},
|
||||
connector_id: connector_id.map(str::to_string),
|
||||
connector_name: connector_name.map(str::to_string),
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7580,6 +7643,7 @@ mod tests {
|
||||
let previous_model = "forked-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -8554,6 +8618,43 @@ mod tests {
|
||||
assert_eq!(submitted.trace, Some(expected_trace));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_default_turn_captures_current_span_trace_id() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
|
||||
init_test_tracing();
|
||||
|
||||
let request_parent = W3cTraceContext {
|
||||
traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()),
|
||||
tracestate: Some("vendor=value".into()),
|
||||
};
|
||||
let request_span = info_span!("app_server.request");
|
||||
assert!(set_parent_from_w3c_trace_context(
|
||||
&request_span,
|
||||
&request_parent
|
||||
));
|
||||
|
||||
let turn_context_item = async {
|
||||
let expected_trace_id = Span::current()
|
||||
.context()
|
||||
.span()
|
||||
.span_context()
|
||||
.trace_id()
|
||||
.to_string();
|
||||
let turn_context = session.new_default_turn().await;
|
||||
let turn_context_item = turn_context.to_turn_context_item();
|
||||
assert_eq!(turn_context_item.trace_id, Some(expected_trace_id));
|
||||
turn_context_item
|
||||
}
|
||||
.instrument(request_span)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
turn_context_item.trace_id.as_deref(),
|
||||
Some("00000000000000000000000000000011")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submission_dispatch_span_prefers_submission_trace_context() {
|
||||
init_test_tracing();
|
||||
|
||||
@@ -40,6 +40,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -78,6 +79,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
let previous_model = "previous-rollout-model";
|
||||
let mut previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -739,6 +741,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -810,6 +813,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
.expect("serialize seeded reference context item"),
|
||||
serde_json::to_value(Some(TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -838,6 +842,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -943,6 +948,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
let unmatched_abort_turn_id = "other-turn".to_string();
|
||||
let current_context_item = TurnContextItem {
|
||||
turn_id: Some(current_turn_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -1044,6 +1050,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -1187,6 +1194,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
|
||||
+204
-25
@@ -1,7 +1,9 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
@@ -26,11 +28,14 @@ use crate::default_client::is_first_party_chat_originator;
|
||||
use crate::default_client::originator;
|
||||
use crate::features::Feature;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::mcp::ToolPluginProvenance;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::with_codex_apps_mcp;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::token_data::TokenData;
|
||||
|
||||
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||
@@ -123,9 +128,12 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
let auth_manager = auth_manager_from_config(config);
|
||||
let auth = auth_manager.auth().await;
|
||||
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
|
||||
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config);
|
||||
if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key)
|
||||
{
|
||||
let cached_connectors = filter_disallowed_connectors(cached_connectors);
|
||||
let cached_connectors = with_app_plugin_sources(cached_connectors, &tool_plugin_provenance);
|
||||
return Ok(AccessibleConnectorsStatus {
|
||||
connectors: cached_connectors,
|
||||
codex_apps_ready: true,
|
||||
@@ -162,6 +170,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
ToolPluginProvenance::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -210,6 +219,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
if codex_apps_ready || !accessible_connectors.is_empty() {
|
||||
write_cached_accessible_connectors(cache_key, &accessible_connectors);
|
||||
}
|
||||
let accessible_connectors =
|
||||
with_app_plugin_sources(accessible_connectors, &tool_plugin_provenance);
|
||||
Ok(AccessibleConnectorsStatus {
|
||||
connectors: accessible_connectors,
|
||||
codex_apps_ready,
|
||||
@@ -291,13 +302,19 @@ pub fn connector_mention_slug(connector: &AppInfo) -> String {
|
||||
pub(crate) fn accessible_connectors_from_mcp_tools(
|
||||
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
) -> Vec<AppInfo> {
|
||||
// ToolInfo already carries plugin provenance, so app-level plugin sources
|
||||
// can be derived here instead of requiring a separate enrichment pass.
|
||||
let tools = mcp_tools.values().filter_map(|tool| {
|
||||
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return None;
|
||||
}
|
||||
let connector_id = tool.connector_id.as_deref()?;
|
||||
let connector_name = normalize_connector_value(tool.connector_name.as_deref());
|
||||
Some((connector_id.to_string(), connector_name))
|
||||
Some((
|
||||
connector_id.to_string(),
|
||||
connector_name,
|
||||
tool.plugin_display_names.clone(),
|
||||
))
|
||||
});
|
||||
collect_accessible_connectors(tools)
|
||||
}
|
||||
@@ -334,6 +351,9 @@ pub fn merge_connectors(
|
||||
if existing.distribution_channel.is_none() && connector.distribution_channel.is_some() {
|
||||
existing.distribution_channel = connector.distribution_channel;
|
||||
}
|
||||
existing
|
||||
.plugin_display_names
|
||||
.extend(connector.plugin_display_names);
|
||||
} else {
|
||||
merged.insert(connector_id, connector);
|
||||
}
|
||||
@@ -344,6 +364,8 @@ pub fn merge_connectors(
|
||||
if connector.install_url.is_none() {
|
||||
connector.install_url = Some(connector_install_url(&connector.name, &connector.id));
|
||||
}
|
||||
connector.plugin_display_names.sort_unstable();
|
||||
connector.plugin_display_names.dedup();
|
||||
}
|
||||
merged.sort_by(|left, right| {
|
||||
right
|
||||
@@ -407,6 +429,18 @@ pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) ->
|
||||
connectors
|
||||
}
|
||||
|
||||
pub fn with_app_plugin_sources(
|
||||
mut connectors: Vec<AppInfo>,
|
||||
tool_plugin_provenance: &ToolPluginProvenance,
|
||||
) -> Vec<AppInfo> {
|
||||
for connector in &mut connectors {
|
||||
connector.plugin_display_names = tool_plugin_provenance
|
||||
.plugin_display_names_for_connector_id(connector.id.as_str())
|
||||
.to_vec();
|
||||
}
|
||||
connectors
|
||||
}
|
||||
|
||||
pub(crate) fn app_tool_policy(
|
||||
config: &Config,
|
||||
connector_id: Option<&str>,
|
||||
@@ -579,35 +613,49 @@ fn app_tool_policy_from_apps_config(
|
||||
|
||||
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
|
||||
where
|
||||
I: IntoIterator<Item = (String, Option<String>)>,
|
||||
I: IntoIterator<Item = (String, Option<String>, Vec<String>)>,
|
||||
{
|
||||
let mut connectors: HashMap<String, String> = HashMap::new();
|
||||
for (connector_id, connector_name) in tools {
|
||||
let mut connectors: HashMap<String, (String, BTreeSet<String>)> = HashMap::new();
|
||||
for (connector_id, connector_name, plugin_display_names) in tools {
|
||||
let connector_name = connector_name.unwrap_or_else(|| connector_id.clone());
|
||||
if let Some(existing_name) = connectors.get_mut(&connector_id) {
|
||||
if let Some((existing_name, existing_plugin_display_names)) =
|
||||
connectors.get_mut(&connector_id)
|
||||
{
|
||||
if existing_name == &connector_id && connector_name != connector_id {
|
||||
*existing_name = connector_name;
|
||||
}
|
||||
existing_plugin_display_names.extend(plugin_display_names);
|
||||
} else {
|
||||
connectors.insert(connector_id, connector_name);
|
||||
connectors.insert(
|
||||
connector_id,
|
||||
(
|
||||
connector_name,
|
||||
plugin_display_names
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<String>>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut accessible: Vec<AppInfo> = connectors
|
||||
.into_iter()
|
||||
.map(|(connector_id, connector_name)| AppInfo {
|
||||
id: connector_id.clone(),
|
||||
name: connector_name.clone(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(connector_install_url(&connector_name, &connector_id)),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
})
|
||||
.map(
|
||||
|(connector_id, (connector_name, plugin_display_names))| AppInfo {
|
||||
id: connector_id.clone(),
|
||||
name: connector_name.clone(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(connector_install_url(&connector_name, &connector_id)),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: plugin_display_names.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
accessible.sort_by(|left, right| {
|
||||
right
|
||||
@@ -638,6 +686,7 @@ fn plugin_app_to_app_info(connector_id: AppConnectorId) -> AppInfo {
|
||||
install_url: Some(connector_install_url(&name, &connector_id)),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,7 +730,11 @@ mod tests {
|
||||
use crate::config::types::AppToolConfig;
|
||||
use crate::config::types::AppToolsConfig;
|
||||
use crate::config::types::AppsDefaultConfig;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Tool;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn annotations(
|
||||
destructive_hint: Option<bool>,
|
||||
@@ -710,13 +763,30 @@ mod tests {
|
||||
labels: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() {
|
||||
let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
let accessible = AppInfo {
|
||||
fn plugin_names(names: &[&str]) -> Vec<String> {
|
||||
names.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
fn test_tool_definition(tool_name: &str) -> Tool {
|
||||
Tool {
|
||||
name: tool_name.to_string().into(),
|
||||
title: None,
|
||||
description: None,
|
||||
input_schema: Arc::new(JsonObject::default()),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn google_calendar_accessible_connector(plugin_display_names: &[&str]) -> AppInfo {
|
||||
AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: Some("Plan events".to_string()),
|
||||
@@ -729,7 +799,30 @@ mod tests {
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
};
|
||||
plugin_display_names: plugin_names(plugin_display_names),
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_app_tool(
|
||||
tool_name: &str,
|
||||
connector_id: &str,
|
||||
connector_name: Option<&str>,
|
||||
plugin_display_names: &[&str],
|
||||
) -> ToolInfo {
|
||||
ToolInfo {
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
tool: test_tool_definition(tool_name),
|
||||
connector_id: Some(connector_id.to_string()),
|
||||
connector_name: connector_name.map(ToOwned::to_owned),
|
||||
plugin_display_names: plugin_names(plugin_display_names),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() {
|
||||
let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
let accessible = google_calendar_accessible_connector(&[]);
|
||||
|
||||
let merged = merge_connectors(vec![plugin], vec![accessible]);
|
||||
|
||||
@@ -748,11 +841,97 @@ mod tests {
|
||||
install_url: Some(connector_install_url("calendar", "calendar")),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(connector_mention_slug(&merged[0]), "google-calendar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() {
|
||||
let tools = HashMap::from([
|
||||
(
|
||||
"mcp__codex_apps__calendar_list_events".to_string(),
|
||||
codex_app_tool(
|
||||
"calendar_list_events",
|
||||
"calendar",
|
||||
None,
|
||||
&["sample", "sample"],
|
||||
),
|
||||
),
|
||||
(
|
||||
"mcp__codex_apps__calendar_create_event".to_string(),
|
||||
codex_app_tool(
|
||||
"calendar_create_event",
|
||||
"calendar",
|
||||
Some("Google Calendar"),
|
||||
&["beta", "sample"],
|
||||
),
|
||||
),
|
||||
(
|
||||
"mcp__sample__echo".to_string(),
|
||||
ToolInfo {
|
||||
server_name: "sample".to_string(),
|
||||
tool_name: "echo".to_string(),
|
||||
tool: test_tool_definition("echo"),
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
plugin_display_names: plugin_names(&["ignored"]),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let connectors = accessible_connectors_from_mcp_tools(&tools);
|
||||
|
||||
assert_eq!(
|
||||
connectors,
|
||||
vec![AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some(connector_install_url("Google Calendar", "calendar")),
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: plugin_names(&["beta", "sample"]),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_unions_and_dedupes_plugin_display_names() {
|
||||
let mut plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
plugin.plugin_display_names = plugin_names(&["sample", "alpha", "sample"]);
|
||||
|
||||
let accessible = google_calendar_accessible_connector(&["beta", "alpha"]);
|
||||
|
||||
let merged = merge_connectors(vec![plugin], vec![accessible]);
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
vec![AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: Some("Plan events".to_string()),
|
||||
logo_url: Some("https://example.com/logo.png".to_string()),
|
||||
logo_url_dark: Some("https://example.com/logo-dark.png".to_string()),
|
||||
distribution_channel: Some("workspace".to_string()),
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(connector_install_url("calendar", "calendar")),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: plugin_names(&["alpha", "beta", "sample"]),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_tool_policy_uses_global_defaults_for_destructive_hints() {
|
||||
let apps_config = AppsConfigToml {
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::mcp_connection_manager::SandboxState;
|
||||
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::PluginsManager;
|
||||
|
||||
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
|
||||
@@ -35,6 +36,64 @@ const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
|
||||
const OPENAI_CONNECTORS_MCP_BASE_URL: &str = "https://api.openai.com";
|
||||
const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/gateways/flat/mcp";
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ToolPluginProvenance {
|
||||
plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
|
||||
plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl ToolPluginProvenance {
|
||||
pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] {
|
||||
self.plugin_display_names_by_connector_id
|
||||
.get(connector_id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] {
|
||||
self.plugin_display_names_by_mcp_server_name
|
||||
.get(server_name)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn from_capability_summaries(capability_summaries: &[PluginCapabilitySummary]) -> Self {
|
||||
let mut tool_plugin_provenance = Self::default();
|
||||
for plugin in capability_summaries {
|
||||
for connector_id in &plugin.app_connector_ids {
|
||||
tool_plugin_provenance
|
||||
.plugin_display_names_by_connector_id
|
||||
.entry(connector_id.0.clone())
|
||||
.or_default()
|
||||
.push(plugin.display_name.clone());
|
||||
}
|
||||
|
||||
for server_name in &plugin.mcp_server_names {
|
||||
tool_plugin_provenance
|
||||
.plugin_display_names_by_mcp_server_name
|
||||
.entry(server_name.clone())
|
||||
.or_default()
|
||||
.push(plugin.display_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for plugin_names in tool_plugin_provenance
|
||||
.plugin_display_names_by_connector_id
|
||||
.values_mut()
|
||||
.chain(
|
||||
tool_plugin_provenance
|
||||
.plugin_display_names_by_mcp_server_name
|
||||
.values_mut(),
|
||||
)
|
||||
{
|
||||
plugin_names.sort_unstable();
|
||||
plugin_names.dedup();
|
||||
}
|
||||
|
||||
tool_plugin_provenance
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy vs new MCP gateway
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CodexAppsMcpGateway {
|
||||
@@ -182,6 +241,11 @@ impl McpManager {
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
effective_mcp_servers(config, auth, self.plugins_manager.as_ref())
|
||||
}
|
||||
|
||||
pub fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance {
|
||||
let loaded_plugins = self.plugins_manager.plugins_for_config(config);
|
||||
ToolPluginProvenance::from_capability_summaries(loaded_plugins.capability_summaries())
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_mcp_servers(
|
||||
@@ -219,6 +283,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
||||
let auth = auth_manager.auth().await;
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
|
||||
let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref());
|
||||
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config);
|
||||
if mcp_servers.is_empty() {
|
||||
return McpListToolsResponseEvent {
|
||||
tools: HashMap::new(),
|
||||
@@ -251,6 +316,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
tool_plugin_provenance,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -407,6 +473,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -485,6 +553,47 @@ mod tests {
|
||||
assert_eq!(group_tools_by_server(&tools), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_plugin_provenance_collects_app_and_mcp_sources() {
|
||||
let provenance = ToolPluginProvenance::from_capability_summaries(&[
|
||||
PluginCapabilitySummary {
|
||||
display_name: "alpha-plugin".to_string(),
|
||||
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
|
||||
mcp_server_names: vec!["alpha".to_string()],
|
||||
..PluginCapabilitySummary::default()
|
||||
},
|
||||
PluginCapabilitySummary {
|
||||
display_name: "beta-plugin".to_string(),
|
||||
app_connector_ids: vec![
|
||||
AppConnectorId("connector_example".to_string()),
|
||||
AppConnectorId("connector_gmail".to_string()),
|
||||
],
|
||||
mcp_server_names: vec!["beta".to_string()],
|
||||
..PluginCapabilitySummary::default()
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
provenance,
|
||||
ToolPluginProvenance {
|
||||
plugin_display_names_by_connector_id: HashMap::from([
|
||||
(
|
||||
"connector_example".to_string(),
|
||||
vec!["alpha-plugin".to_string(), "beta-plugin".to_string()],
|
||||
),
|
||||
(
|
||||
"connector_gmail".to_string(),
|
||||
vec!["beta-plugin".to_string()],
|
||||
),
|
||||
]),
|
||||
plugin_display_names_by_mcp_server_name: HashMap::from([
|
||||
("alpha".to_string(), vec!["alpha-plugin".to_string()]),
|
||||
("beta".to_string(), vec!["beta-plugin".to_string()]),
|
||||
]),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_mcp_url_for_default_gateway_keeps_existing_paths() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! in a single aggregated map using the fully-qualified tool name
|
||||
//! `"<server><MCP_TOOL_NAME_DELIMITER><tool>"` as the key.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
@@ -19,6 +20,7 @@ use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::ToolPluginProvenance;
|
||||
use crate::mcp::auth::McpAuthStatusEntry;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
@@ -27,6 +29,7 @@ use async_channel::Sender;
|
||||
use codex_async_utils::CancelErr;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_config::Constrained;
|
||||
use codex_protocol::approvals::ElicitationRequest;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::mcp::RequestId as ProtocolRequestId;
|
||||
@@ -79,7 +82,6 @@ use crate::codex::INITIAL_SUBMIT_ID;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::connectors::is_connector_id_allowed;
|
||||
|
||||
/// Delimiter used to separate the server name from the tool name in a fully
|
||||
/// qualified tool name.
|
||||
///
|
||||
@@ -197,6 +199,8 @@ pub(crate) struct ToolInfo {
|
||||
pub(crate) tool: Tool,
|
||||
pub(crate) connector_id: Option<String>,
|
||||
pub(crate) connector_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) plugin_display_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -294,6 +298,27 @@ impl ElicitationRequestManager {
|
||||
});
|
||||
}
|
||||
|
||||
let request = match elicitation {
|
||||
CreateElicitationRequestParams::FormElicitationParams {
|
||||
message,
|
||||
requested_schema,
|
||||
..
|
||||
} => ElicitationRequest::Form {
|
||||
message,
|
||||
requested_schema: serde_json::to_value(requested_schema)
|
||||
.context("failed to serialize MCP elicitation schema")?,
|
||||
},
|
||||
CreateElicitationRequestParams::UrlElicitationParams {
|
||||
message,
|
||||
url,
|
||||
elicitation_id,
|
||||
..
|
||||
} => ElicitationRequest::Url {
|
||||
message,
|
||||
url,
|
||||
elicitation_id,
|
||||
},
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
{
|
||||
let mut lock = elicitation_requests.lock().await;
|
||||
@@ -312,16 +337,7 @@ impl ElicitationRequestManager {
|
||||
ProtocolRequestId::Integer(value)
|
||||
}
|
||||
},
|
||||
message: match elicitation {
|
||||
CreateElicitationRequestParams::FormElicitationParams {
|
||||
message,
|
||||
..
|
||||
}
|
||||
| CreateElicitationRequestParams::UrlElicitationParams {
|
||||
message,
|
||||
..
|
||||
} => message,
|
||||
},
|
||||
request,
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
@@ -391,9 +407,13 @@ struct AsyncManagedClient {
|
||||
client: Shared<BoxFuture<'static, Result<ManagedClient, StartupOutcomeError>>>,
|
||||
startup_snapshot: Option<Vec<ToolInfo>>,
|
||||
startup_complete: Arc<AtomicBool>,
|
||||
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||
}
|
||||
|
||||
impl AsyncManagedClient {
|
||||
// Keep this constructor flat so the startup inputs remain readable at the
|
||||
// single call site instead of introducing a one-off params wrapper.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
server_name: String,
|
||||
config: McpServerConfig,
|
||||
@@ -402,6 +422,7 @@ impl AsyncManagedClient {
|
||||
tx_event: Sender<Event>,
|
||||
elicitation_requests: ElicitationRequestManager,
|
||||
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
||||
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||
) -> Self {
|
||||
let tool_filter = ToolFilter::from_config(&config);
|
||||
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
|
||||
@@ -458,6 +479,7 @@ impl AsyncManagedClient {
|
||||
client,
|
||||
startup_snapshot,
|
||||
startup_complete,
|
||||
tool_plugin_provenance,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,14 +495,63 @@ impl AsyncManagedClient {
|
||||
}
|
||||
|
||||
async fn listed_tools(&self) -> Option<Vec<ToolInfo>> {
|
||||
if let Some(startup_tools) = self.startup_snapshot_while_initializing() {
|
||||
return Some(startup_tools);
|
||||
}
|
||||
let annotate_tools = |tools: Vec<ToolInfo>| {
|
||||
let mut tools = tools;
|
||||
for tool in &mut tools {
|
||||
let plugin_names = match tool.connector_id.as_deref() {
|
||||
Some(connector_id) => self
|
||||
.tool_plugin_provenance
|
||||
.plugin_display_names_for_connector_id(connector_id),
|
||||
None => self
|
||||
.tool_plugin_provenance
|
||||
.plugin_display_names_for_mcp_server_name(tool.server_name.as_str()),
|
||||
};
|
||||
tool.plugin_display_names = plugin_names.to_vec();
|
||||
|
||||
match self.client().await {
|
||||
Ok(client) => Some(client.listed_tools()),
|
||||
Err(_) => self.startup_snapshot.clone(),
|
||||
}
|
||||
if plugin_names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let plugin_source_note = if plugin_names.len() == 1 {
|
||||
format!("This tool is part of plugin `{}`.", plugin_names[0])
|
||||
} else {
|
||||
format!(
|
||||
"This tool is part of plugins {}.",
|
||||
plugin_names
|
||||
.iter()
|
||||
.map(|plugin_name| format!("`{plugin_name}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
};
|
||||
let description = tool
|
||||
.tool
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
let annotated_description = if description.is_empty() {
|
||||
plugin_source_note
|
||||
} else if matches!(description.chars().last(), Some('.' | '!' | '?')) {
|
||||
format!("{description} {plugin_source_note}")
|
||||
} else {
|
||||
format!("{description}. {plugin_source_note}")
|
||||
};
|
||||
tool.tool.description = Some(Cow::Owned(annotated_description));
|
||||
}
|
||||
tools
|
||||
};
|
||||
|
||||
// Keep cache payloads raw; plugin provenance is resolved per-session at read time.
|
||||
let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() {
|
||||
Some(startup_tools)
|
||||
} else {
|
||||
match self.client().await {
|
||||
Ok(client) => Some(client.listed_tools()),
|
||||
Err(_) => self.startup_snapshot.clone(),
|
||||
}
|
||||
};
|
||||
tools.map(annotate_tools)
|
||||
}
|
||||
|
||||
async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> {
|
||||
@@ -552,12 +623,14 @@ impl McpConnectionManager {
|
||||
initial_sandbox_state: SandboxState,
|
||||
codex_home: PathBuf,
|
||||
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
|
||||
tool_plugin_provenance: ToolPluginProvenance,
|
||||
) -> (Self, CancellationToken) {
|
||||
let cancel_token = CancellationToken::new();
|
||||
let mut clients = HashMap::new();
|
||||
let mut server_origins = HashMap::new();
|
||||
let mut join_set = JoinSet::new();
|
||||
let elicitation_requests = ElicitationRequestManager::new(approval_policy.value());
|
||||
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
|
||||
let mcp_servers = mcp_servers.clone();
|
||||
for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) {
|
||||
if let Some(origin) = transport_origin(&cfg.transport) {
|
||||
@@ -588,6 +661,7 @@ impl McpConnectionManager {
|
||||
tx_event.clone(),
|
||||
elicitation_requests.clone(),
|
||||
codex_apps_tools_cache_context,
|
||||
Arc::clone(&tool_plugin_provenance),
|
||||
);
|
||||
clients.insert(server_name.clone(), async_managed_client.clone());
|
||||
let tx_event = tx_event.clone();
|
||||
@@ -1495,6 +1569,7 @@ async fn list_tools_for_client_uncached(
|
||||
tool: tool_def,
|
||||
connector_id: tool.connector_id,
|
||||
connector_name,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -1608,6 +1683,7 @@ mod tests {
|
||||
},
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1991,6 +2067,7 @@ mod tests {
|
||||
client: pending_client,
|
||||
startup_snapshot: Some(startup_tools),
|
||||
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2016,6 +2093,7 @@ mod tests {
|
||||
client: pending_client,
|
||||
startup_snapshot: None,
|
||||
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2038,6 +2116,7 @@ mod tests {
|
||||
client: pending_client,
|
||||
startup_snapshot: Some(Vec::new()),
|
||||
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2069,6 +2148,7 @@ mod tests {
|
||||
client: failed_client,
|
||||
startup_snapshot: Some(startup_tools),
|
||||
startup_complete,
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::path::PathBuf;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::injection::ToolMentionKind;
|
||||
use crate::skills::injection::app_id_from_path;
|
||||
@@ -48,6 +49,103 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Collect explicit plain-text `@plugin` mentions from user text.
|
||||
///
|
||||
/// This is currently the core-side fallback path for plugin mentions. It
|
||||
/// matches unambiguous plugin `display_name`s from the filtered capability
|
||||
/// index, case-insensitively, by scanning for exact `@display name` matches.
|
||||
///
|
||||
/// It is hand-rolled because core only has a `$...` / `[$...](...)` mention
|
||||
/// parser today, and the existing TUI `@...` logic is file-autocomplete, not
|
||||
/// turn-time parsing.
|
||||
///
|
||||
/// Long term, explicit plugin picks should come through structured
|
||||
/// `plugin://...` mentions, likely via `UserInput::Mention`, once clients can list
|
||||
/// plugins and the UI has plugin-mention support (likely a plugins/list app-server
|
||||
/// endpoint). Even then, this may stay as a text fallback, similar to skills/apps.
|
||||
pub(crate) fn collect_explicit_plugin_mentions(
|
||||
input: &[UserInput],
|
||||
plugins: &[PluginCapabilitySummary],
|
||||
) -> Vec<PluginCapabilitySummary> {
|
||||
if plugins.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut display_name_counts = HashMap::new();
|
||||
for plugin in plugins {
|
||||
*display_name_counts
|
||||
.entry(plugin.display_name.to_lowercase())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mut display_names = display_name_counts.keys().cloned().collect::<Vec<_>>();
|
||||
display_names.sort_by_key(|display_name| std::cmp::Reverse(display_name.len()));
|
||||
|
||||
let mut mentioned_display_names = HashSet::new();
|
||||
for text in input.iter().filter_map(|item| match item {
|
||||
UserInput::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
}) {
|
||||
let text = text.to_lowercase();
|
||||
let mut index = 0;
|
||||
while let Some(relative_at_sign) = text[index..].find('@') {
|
||||
let at_sign = index + relative_at_sign;
|
||||
if text[..at_sign]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(is_plugin_mention_body_char)
|
||||
{
|
||||
index = at_sign + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((matched_display_name, matched_len)) =
|
||||
display_names.iter().find_map(|display_name| {
|
||||
text[at_sign + 1..].starts_with(display_name).then(|| {
|
||||
let end = at_sign + 1 + display_name.len();
|
||||
text[end..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_none_or(|ch| !is_plugin_mention_body_char(ch))
|
||||
.then_some((display_name, display_name.len()))
|
||||
})?
|
||||
})
|
||||
else {
|
||||
index = at_sign + 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
if display_name_counts
|
||||
.get(matched_display_name)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
== 1
|
||||
{
|
||||
mentioned_display_names.insert(matched_display_name.clone());
|
||||
}
|
||||
index = at_sign + 1 + matched_len;
|
||||
}
|
||||
}
|
||||
|
||||
if mentioned_display_names.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut selected = Vec::new();
|
||||
let mut seen_display_names = HashSet::new();
|
||||
for plugin in plugins {
|
||||
let display_name = plugin.display_name.to_lowercase();
|
||||
if !mentioned_display_names.contains(&display_name) {
|
||||
continue;
|
||||
}
|
||||
if seen_display_names.insert(display_name) {
|
||||
selected.push(plugin.clone());
|
||||
}
|
||||
}
|
||||
|
||||
selected
|
||||
}
|
||||
|
||||
pub(crate) fn build_skill_name_counts(
|
||||
skills: &[SkillMetadata],
|
||||
disabled_paths: &HashSet<PathBuf>,
|
||||
@@ -77,6 +175,10 @@ pub(crate) fn build_connector_slug_counts(
|
||||
counts
|
||||
}
|
||||
|
||||
fn is_plugin_mention_body_char(ch: char) -> bool {
|
||||
ch.is_alphanumeric() || matches!(ch, '_' | '-' | ':')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
@@ -85,6 +187,8 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::collect_explicit_app_ids;
|
||||
use super::collect_explicit_plugin_mentions;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
|
||||
fn text_input(text: &str) -> UserInput {
|
||||
UserInput::Text {
|
||||
@@ -93,6 +197,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin(display_name: &str) -> PluginCapabilitySummary {
|
||||
PluginCapabilitySummary {
|
||||
config_name: format!("{display_name}@test"),
|
||||
display_name: display_name.to_string(),
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_app_ids_from_linked_text_mentions() {
|
||||
let input = vec")];
|
||||
@@ -141,4 +255,70 @@ mod tests {
|
||||
|
||||
assert_eq!(app_ids, HashSet::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_plugin_mentions_resolves_unique_display_names() {
|
||||
let plugins = vec![plugin("sample"), plugin("other")];
|
||||
|
||||
let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins);
|
||||
|
||||
assert_eq!(mentioned, vec![plugin("sample")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_plugin_mentions_resolves_non_slug_display_names() {
|
||||
let spaced_plugins = vec![plugin("Google Calendar")];
|
||||
let spaced_mentioned = collect_explicit_plugin_mentions(
|
||||
&[text_input("use @Google Calendar")],
|
||||
&spaced_plugins,
|
||||
);
|
||||
assert_eq!(spaced_mentioned, vec![plugin("Google Calendar")]);
|
||||
|
||||
let unicode_plugins = vec![plugin("Café")];
|
||||
let unicode_mentioned =
|
||||
collect_explicit_plugin_mentions(&[text_input("use @Café")], &unicode_plugins);
|
||||
assert_eq!(unicode_mentioned, vec![plugin("Café")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_plugin_mentions_prefers_longer_display_names() {
|
||||
let plugins = vec![plugin("Google"), plugin("Google Calendar")];
|
||||
|
||||
let mentioned =
|
||||
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
|
||||
|
||||
assert_eq!(mentioned, vec![plugin("Google Calendar")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_plugin_mentions_does_not_fall_back_from_ambiguous_longer_name() {
|
||||
let plugins = vec![
|
||||
plugin("Google"),
|
||||
PluginCapabilitySummary {
|
||||
config_name: "calendar-1@test".to_string(),
|
||||
..plugin("Google Calendar")
|
||||
},
|
||||
PluginCapabilitySummary {
|
||||
config_name: "calendar-2@test".to_string(),
|
||||
..plugin("Google Calendar")
|
||||
},
|
||||
];
|
||||
|
||||
let mentioned =
|
||||
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
|
||||
|
||||
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_plugin_mentions_ignores_embedded_at_signs() {
|
||||
let plugins = vec![plugin("sample")];
|
||||
|
||||
let mentioned = collect_explicit_plugin_mentions(
|
||||
&[text_input("contact sample@openai.com, do not use plugins")],
|
||||
&plugins,
|
||||
);
|
||||
|
||||
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::render_explicit_plugin_instructions;
|
||||
|
||||
pub(crate) fn build_plugin_injections(
|
||||
mentioned_plugins: &[PluginCapabilitySummary],
|
||||
mcp_tools: &HashMap<String, ToolInfo>,
|
||||
available_connectors: &[connectors::AppInfo],
|
||||
) -> Vec<ResponseItem> {
|
||||
if mentioned_plugins.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Turn each explicit @plugin mention into a developer hint that points the
|
||||
// model at the plugin's visible MCP servers, enabled apps, and skill prefix.
|
||||
mentioned_plugins
|
||||
.iter()
|
||||
.filter_map(|plugin| {
|
||||
let available_mcp_servers = mcp_tools
|
||||
.values()
|
||||
.filter(|tool| {
|
||||
tool.server_name != CODEX_APPS_MCP_SERVER_NAME
|
||||
&& tool
|
||||
.plugin_display_names
|
||||
.iter()
|
||||
.any(|plugin_name| plugin_name == &plugin.display_name)
|
||||
})
|
||||
.map(|tool| tool.server_name.clone())
|
||||
.collect::<BTreeSet<String>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let available_apps = available_connectors
|
||||
.iter()
|
||||
.filter(|connector| {
|
||||
connector.is_enabled
|
||||
&& connector
|
||||
.plugin_display_names
|
||||
.iter()
|
||||
.any(|plugin_name| plugin_name == &plugin.display_name)
|
||||
})
|
||||
.map(connectors::connector_display_label)
|
||||
.collect::<BTreeSet<String>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
render_explicit_plugin_instructions(plugin, &available_mcp_servers, &available_apps)
|
||||
.map(DeveloperInstructions::new)
|
||||
.map(ResponseItem::from)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -198,11 +198,6 @@ impl PluginsManager {
|
||||
force_reload: bool,
|
||||
) -> PluginLoadOutcome {
|
||||
if !plugins_feature_enabled_from_stack(config_layer_stack) {
|
||||
let mut cache = match self.cache_by_cwd.write() {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
cache.insert(cwd.to_path_buf(), PluginLoadOutcome::default());
|
||||
return PluginLoadOutcome::default();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
mod injection;
|
||||
mod manager;
|
||||
mod manifest;
|
||||
mod marketplace;
|
||||
mod render;
|
||||
mod store;
|
||||
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use manager::AppConnectorId;
|
||||
pub use manager::LoadedPlugin;
|
||||
pub use manager::PluginCapabilitySummary;
|
||||
@@ -14,6 +16,7 @@ pub use manager::PluginsManager;
|
||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||
pub(crate) use manifest::load_plugin_manifest;
|
||||
pub(crate) use manifest::plugin_manifest_name;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
pub(crate) use render::render_plugins_section;
|
||||
pub use store::PluginId;
|
||||
pub use store::PluginInstallResult;
|
||||
|
||||
@@ -30,6 +30,54 @@ pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Opt
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn render_explicit_plugin_instructions(
|
||||
plugin: &PluginCapabilitySummary,
|
||||
available_mcp_servers: &[String],
|
||||
available_apps: &[String],
|
||||
) -> Option<String> {
|
||||
let mut lines = vec![format!(
|
||||
"Capabilities from the `{}` plugin:",
|
||||
plugin.display_name
|
||||
)];
|
||||
|
||||
if plugin.has_skills {
|
||||
lines.push(format!(
|
||||
"- Skills from this plugin are prefixed with `{}:`.",
|
||||
plugin.display_name
|
||||
));
|
||||
}
|
||||
|
||||
if !available_mcp_servers.is_empty() {
|
||||
lines.push(format!(
|
||||
"- MCP servers from this plugin available in this session: {}.",
|
||||
available_mcp_servers
|
||||
.iter()
|
||||
.map(|server| format!("`{server}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if !available_apps.is_empty() {
|
||||
lines.push(format!(
|
||||
"- Apps from this plugin available in this session: {}.",
|
||||
available_apps
|
||||
.iter()
|
||||
.map(|app| format!("`{app}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if lines.len() == 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
lines.push("Use these plugin-associated capabilities to help solve the task.".to_string());
|
||||
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -58,13 +58,11 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
|
||||
"- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n",
|
||||
);
|
||||
section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n");
|
||||
section.push_str("- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n");
|
||||
section.push_str("- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n");
|
||||
section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n");
|
||||
section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n");
|
||||
if config.features.enabled(Feature::ImageDetailOriginal) {
|
||||
section.push_str("- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\n");
|
||||
}
|
||||
section.push_str("- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n");
|
||||
section.push_str("- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n");
|
||||
section.push_str("- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n");
|
||||
section.push_str("- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n");
|
||||
|
||||
if config.features.enabled(Feature::JsReplToolsOnly) {
|
||||
@@ -492,7 +490,7 @@ mod tests {
|
||||
let res = get_user_instructions(&cfg, None, None)
|
||||
.await
|
||||
.expect("js_repl instructions expected");
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
@@ -511,12 +509,12 @@ mod tests {
|
||||
let res = get_user_instructions(&cfg, None, None)
|
||||
.await
|
||||
.expect("js_repl instructions expected");
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn js_repl_original_resolution_guidance_is_feature_gated() {
|
||||
async fn js_repl_image_detail_original_does_not_change_instructions() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, 4096, None).await;
|
||||
let mut features = cfg.features.get().clone();
|
||||
@@ -530,7 +528,7 @@ mod tests {
|
||||
let res = get_user_instructions(&cfg, None, None)
|
||||
.await
|
||||
.expect("js_repl instructions expected");
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
|
||||
@@ -1404,6 +1404,7 @@ mod tests {
|
||||
timestamp: "2025-01-03T13:00:01Z".to_string(),
|
||||
item: RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
trace_id: None,
|
||||
cwd: latest_cwd.clone(),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
|
||||
@@ -129,7 +129,7 @@ impl Default for UnixDomainSocketPolicy {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct UnixSocketPathParam {
|
||||
key: String,
|
||||
index: usize,
|
||||
path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
@@ -200,17 +200,23 @@ fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec<UnixSocketPathParam
|
||||
deduped_paths
|
||||
.into_values()
|
||||
.enumerate()
|
||||
.map(|(index, path)| UnixSocketPathParam {
|
||||
key: format!("UNIX_SOCKET_PATH_{index}"),
|
||||
path,
|
||||
})
|
||||
.map(|(index, path)| UnixSocketPathParam { index, path })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unix_socket_path_param_key(index: usize) -> String {
|
||||
format!("UNIX_SOCKET_PATH_{index}")
|
||||
}
|
||||
|
||||
fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> {
|
||||
unix_socket_path_params(proxy)
|
||||
.into_iter()
|
||||
.map(|param| (param.key, param.path.into_path_buf()))
|
||||
.map(|param| {
|
||||
(
|
||||
unix_socket_path_param_key(param.index),
|
||||
param.path.into_path_buf(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -218,17 +224,39 @@ fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> {
|
||||
/// When non-empty, the returned string is newline-terminated so callers can
|
||||
/// append it directly to larger policy blocks.
|
||||
fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String {
|
||||
let socket_params = unix_socket_path_params(proxy);
|
||||
let has_unix_socket_access = matches!(
|
||||
proxy.unix_domain_socket_policy,
|
||||
UnixDomainSocketPolicy::AllowAll
|
||||
) || !socket_params.is_empty();
|
||||
if !has_unix_socket_access {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut policy = String::new();
|
||||
policy.push_str("(allow system-socket (socket-domain AF_UNIX))\n");
|
||||
if matches!(
|
||||
proxy.unix_domain_socket_policy,
|
||||
UnixDomainSocketPolicy::AllowAll
|
||||
) {
|
||||
return "(allow network* (subpath \"/\"))\n".to_string();
|
||||
// Keep AllowAll genuinely broad here; path qualifiers look narrower
|
||||
// without a clear macOS behavioral benefit.
|
||||
policy.push_str("(allow network-bind (local unix-socket))\n");
|
||||
policy.push_str("(allow network-outbound (remote unix-socket))\n");
|
||||
return policy;
|
||||
}
|
||||
|
||||
unix_socket_path_params(proxy)
|
||||
.iter()
|
||||
.map(|param| format!("(allow network* (subpath (param \"{}\")))\n", param.key))
|
||||
.collect()
|
||||
for param in socket_params {
|
||||
let key = unix_socket_path_param_key(param.index);
|
||||
// Use subpath so allowlists cover sockets created beneath approved directories.
|
||||
policy.push_str(&format!(
|
||||
"(allow network-bind (local unix-socket (subpath (param \"{key}\"))))\n"
|
||||
));
|
||||
policy.push_str(&format!(
|
||||
"(allow network-outbound (remote unix-socket (subpath (param \"{key}\"))))\n"
|
||||
));
|
||||
}
|
||||
policy
|
||||
}
|
||||
|
||||
fn dynamic_network_policy(
|
||||
@@ -236,11 +264,12 @@ fn dynamic_network_policy(
|
||||
enforce_managed_network: bool,
|
||||
proxy: &ProxyPolicyInputs,
|
||||
) -> String {
|
||||
if !proxy.ports.is_empty() {
|
||||
let mut policy =
|
||||
String::from("; allow outbound access only to configured loopback proxy endpoints\n");
|
||||
let should_use_restricted_network_policy =
|
||||
!proxy.ports.is_empty() || proxy.has_proxy_config || enforce_managed_network;
|
||||
if should_use_restricted_network_policy {
|
||||
let mut policy = String::new();
|
||||
if proxy.allow_local_binding {
|
||||
policy.push_str("; allow localhost-only binding and loopback traffic\n");
|
||||
policy.push_str("; allow loopback local binding and loopback traffic\n");
|
||||
policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n");
|
||||
policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n");
|
||||
policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n");
|
||||
@@ -258,18 +287,6 @@ fn dynamic_network_policy(
|
||||
return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}");
|
||||
}
|
||||
|
||||
if proxy.has_proxy_config {
|
||||
// Proxy configuration is present but we could not infer any valid loopback endpoints.
|
||||
// Fail closed to avoid silently widening network access in proxy-enforced sessions.
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if enforce_managed_network {
|
||||
// Managed network requirements are active but no usable proxy endpoints
|
||||
// are available. Fail closed for network access.
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if sandbox_policy.has_full_network_access() {
|
||||
// No proxy env is configured: retain the existing full-network behavior.
|
||||
format!(
|
||||
@@ -681,7 +698,7 @@ sys.exit(0 if allowed else 13)
|
||||
|
||||
assert!(
|
||||
policy.contains("(allow network-bind (local ip \"localhost:*\"))"),
|
||||
"policy should allow loopback binding when explicitly enabled:\n{policy}"
|
||||
"policy should allow loopback local binding when explicitly enabled:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains("(allow network-inbound (local ip \"localhost:*\"))"),
|
||||
@@ -698,7 +715,7 @@ sys.exit(0 if allowed else 13)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_network_policy_fails_closed_when_proxy_config_without_ports() {
|
||||
fn dynamic_network_policy_preserves_restricted_policy_when_proxy_config_without_ports() {
|
||||
let policy = dynamic_network_policy(
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
@@ -716,6 +733,10 @@ sys.exit(0 if allowed else 13)
|
||||
},
|
||||
);
|
||||
|
||||
assert!(
|
||||
policy.contains("(socket-domain AF_SYSTEM)"),
|
||||
"policy should keep the restricted network profile when proxy config is present without ports:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
!policy.contains("\n(allow network-outbound)\n"),
|
||||
"policy should not include blanket outbound allowance when proxy config is present without ports:\n{policy}"
|
||||
@@ -727,7 +748,8 @@ sys.exit(0 if allowed else 13)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_network_policy_fails_closed_for_managed_network_without_proxy_config() {
|
||||
fn dynamic_network_policy_preserves_restricted_policy_for_managed_network_without_proxy_config()
|
||||
{
|
||||
let policy = dynamic_network_policy(
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
@@ -745,7 +767,14 @@ sys.exit(0 if allowed else 13)
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(policy, "");
|
||||
assert!(
|
||||
policy.contains("(socket-domain AF_SYSTEM)"),
|
||||
"policy should keep the restricted network profile when managed network is active without proxy endpoints:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
!policy.contains("\n(allow network-outbound)\n"),
|
||||
"policy should not include blanket outbound allowance when managed network is active without proxy endpoints:\n{policy}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -764,8 +793,24 @@ sys.exit(0 if allowed else 13)
|
||||
);
|
||||
|
||||
assert!(
|
||||
policy.contains("(allow network* (subpath (param \"UNIX_SOCKET_PATH_0\")))"),
|
||||
"policy should allow explicitly configured unix sockets:\n{policy}"
|
||||
policy.contains("(allow system-socket (socket-domain AF_UNIX))"),
|
||||
"policy should allow AF_UNIX socket creation for configured unix sockets:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains(
|
||||
"(allow network-bind (local unix-socket (subpath (param \"UNIX_SOCKET_PATH_0\"))))"
|
||||
),
|
||||
"policy should allow binding explicitly configured unix sockets:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains(
|
||||
"(allow network-outbound (remote unix-socket (subpath (param \"UNIX_SOCKET_PATH_0\"))))"
|
||||
),
|
||||
"policy should allow connecting to explicitly configured unix sockets:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
!policy.contains("(allow network* (subpath"),
|
||||
"policy should no longer use the generic subpath unix-socket rules:\n{policy}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -839,8 +884,20 @@ sys.exit(0 if allowed else 13)
|
||||
);
|
||||
|
||||
assert!(
|
||||
policy.contains("(allow network* (subpath \"/\"))"),
|
||||
"policy should allow all unix sockets when flag is enabled:\n{policy}"
|
||||
policy.contains("(allow system-socket (socket-domain AF_UNIX))"),
|
||||
"policy should allow AF_UNIX socket creation when unix sockets are enabled:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains("(allow network-bind (local unix-socket))"),
|
||||
"policy should allow binding unix sockets when enabled:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains("(allow network-outbound (remote unix-socket))"),
|
||||
"policy should allow connecting to unix sockets when enabled:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
!policy.contains("(allow network* (subpath"),
|
||||
"policy should no longer use the generic subpath unix-socket rules:\n{policy}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ pub(crate) mod invocation_utils;
|
||||
pub mod loader;
|
||||
pub mod manager;
|
||||
pub mod model;
|
||||
pub mod permissions;
|
||||
pub mod remote;
|
||||
pub mod render;
|
||||
pub mod system;
|
||||
|
||||
@@ -1,454 +0,0 @@
|
||||
#[cfg(any(unix, test))]
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsAutomationValue;
|
||||
#[cfg(any(unix, test))]
|
||||
use codex_protocol::models::MacOsPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsPreferencesValue;
|
||||
#[cfg(any(unix, test))]
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
#[cfg(any(unix, test))]
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
#[cfg(any(unix, test))]
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
#[cfg(any(unix, test))]
|
||||
use dunce::canonicalize as canonicalize_path;
|
||||
#[cfg(any(unix, test))]
|
||||
use tracing::warn;
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::config::Constrained;
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::config::Permissions;
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::protocol::AskForApproval;
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::protocol::SandboxPolicy;
|
||||
|
||||
/// Compiles a skill `PermissionProfile` for the Unix shell escalation path.
|
||||
///
|
||||
/// Normal Windows builds do not currently call this helper, so it is only
|
||||
/// compiled on Unix and in tests.
|
||||
#[cfg(any(unix, test))]
|
||||
pub(crate) fn compile_permission_profile(
|
||||
permissions: Option<PermissionProfile>,
|
||||
) -> Option<Permissions> {
|
||||
let PermissionProfile {
|
||||
network,
|
||||
file_system,
|
||||
macos,
|
||||
} = permissions?;
|
||||
let network_access = network.and_then(|value| value.enabled).unwrap_or_default();
|
||||
let file_system = file_system.unwrap_or_default();
|
||||
let fs_read = normalize_permission_paths(
|
||||
file_system.read.as_deref().unwrap_or_default(),
|
||||
"permissions.file_system.read",
|
||||
);
|
||||
let fs_write = normalize_permission_paths(
|
||||
file_system.write.as_deref().unwrap_or_default(),
|
||||
"permissions.file_system.write",
|
||||
);
|
||||
let sandbox_policy = if !fs_write.is_empty() {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: fs_write,
|
||||
read_only_access: if fs_read.is_empty() {
|
||||
ReadOnlyAccess::FullAccess
|
||||
} else {
|
||||
ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: fs_read,
|
||||
}
|
||||
},
|
||||
network_access,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
}
|
||||
} else if !fs_read.is_empty() {
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: fs_read,
|
||||
},
|
||||
network_access,
|
||||
}
|
||||
} else if network_access {
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::FullAccess,
|
||||
network_access: true,
|
||||
}
|
||||
} else {
|
||||
// Default sandbox policy
|
||||
SandboxPolicy::new_read_only_policy()
|
||||
};
|
||||
let macos_permissions = macos.unwrap_or_default();
|
||||
let macos_seatbelt_profile_extensions =
|
||||
build_macos_seatbelt_profile_extensions(&macos_permissions);
|
||||
|
||||
Some(Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(sandbox_policy),
|
||||
network: None,
|
||||
allow_login_shell: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
macos_seatbelt_profile_extensions,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn normalize_permission_paths(values: &[AbsolutePathBuf], field: &str) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for value in values {
|
||||
let Some(path) = normalize_permission_path(value, field) else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(path.clone()) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn normalize_permission_path(value: &AbsolutePathBuf, field: &str) -> Option<AbsolutePathBuf> {
|
||||
let canonicalized = canonicalize_path(value.as_path()).unwrap_or_else(|_| value.to_path_buf());
|
||||
match AbsolutePathBuf::from_absolute_path(&canonicalized) {
|
||||
Ok(path) => Some(path),
|
||||
Err(error) => {
|
||||
warn!("ignoring {field}: expected absolute path, got {canonicalized:?}: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_macos_seatbelt_profile_extensions(
|
||||
permissions: &MacOsPermissions,
|
||||
) -> Option<MacOsSeatbeltProfileExtensions> {
|
||||
let defaults = MacOsSeatbeltProfileExtensions::default();
|
||||
|
||||
let extensions = MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: resolve_macos_preferences_permission(
|
||||
permissions.preferences.as_ref(),
|
||||
defaults.macos_preferences,
|
||||
),
|
||||
macos_automation: resolve_macos_automation_permission(
|
||||
permissions.automations.as_ref(),
|
||||
defaults.macos_automation,
|
||||
),
|
||||
macos_accessibility: permissions
|
||||
.accessibility
|
||||
.unwrap_or(defaults.macos_accessibility),
|
||||
macos_calendar: permissions.calendar.unwrap_or(defaults.macos_calendar),
|
||||
};
|
||||
Some(extensions)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn resolve_macos_preferences_permission(
|
||||
value: Option<&MacOsPreferencesValue>,
|
||||
default: crate::seatbelt_permissions::MacOsPreferencesPermission,
|
||||
) -> crate::seatbelt_permissions::MacOsPreferencesPermission {
|
||||
use crate::seatbelt_permissions::MacOsPreferencesPermission;
|
||||
|
||||
match value {
|
||||
Some(MacOsPreferencesValue::Bool(true)) => MacOsPreferencesPermission::ReadOnly,
|
||||
Some(MacOsPreferencesValue::Bool(false)) => MacOsPreferencesPermission::None,
|
||||
Some(MacOsPreferencesValue::Mode(mode)) => {
|
||||
let mode = mode.trim();
|
||||
if mode.eq_ignore_ascii_case("readonly") || mode.eq_ignore_ascii_case("read-only") {
|
||||
MacOsPreferencesPermission::ReadOnly
|
||||
} else if mode.eq_ignore_ascii_case("readwrite")
|
||||
|| mode.eq_ignore_ascii_case("read-write")
|
||||
{
|
||||
MacOsPreferencesPermission::ReadWrite
|
||||
} else {
|
||||
warn!(
|
||||
"ignoring permissions.macos.preferences: expected true/false, readonly, or readwrite"
|
||||
);
|
||||
default
|
||||
}
|
||||
}
|
||||
None => default,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn resolve_macos_automation_permission(
|
||||
value: Option<&MacOsAutomationValue>,
|
||||
default: crate::seatbelt_permissions::MacOsAutomationPermission,
|
||||
) -> crate::seatbelt_permissions::MacOsAutomationPermission {
|
||||
use crate::seatbelt_permissions::MacOsAutomationPermission;
|
||||
|
||||
match value {
|
||||
Some(MacOsAutomationValue::Bool(true)) => MacOsAutomationPermission::All,
|
||||
Some(MacOsAutomationValue::Bool(false)) => MacOsAutomationPermission::None,
|
||||
Some(MacOsAutomationValue::BundleIds(bundle_ids)) => {
|
||||
let bundle_ids = bundle_ids
|
||||
.iter()
|
||||
.map(|bundle_id| bundle_id.trim())
|
||||
.filter(|bundle_id| !bundle_id.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<String>>();
|
||||
if bundle_ids.is_empty() {
|
||||
MacOsAutomationPermission::None
|
||||
} else {
|
||||
MacOsAutomationPermission::BundleIds(bundle_ids)
|
||||
}
|
||||
}
|
||||
None => default,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), any(unix, test)))]
|
||||
fn build_macos_seatbelt_profile_extensions(
|
||||
_: &MacOsPermissions,
|
||||
) -> Option<MacOsSeatbeltProfileExtensions> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::compile_permission_profile;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::Permissions;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsAutomationValue;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsPreferencesValue;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn absolute_path(path: &Path) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(path).expect("absolute path")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_permission_profile_normalizes_paths() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
fs::create_dir_all(skill_dir.join("scripts")).expect("skill dir");
|
||||
let read_dir = skill_dir.join("data");
|
||||
fs::create_dir_all(&read_dir).expect("read dir");
|
||||
|
||||
let profile = compile_permission_profile(Some(PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![
|
||||
absolute_path(&skill_dir.join("data")),
|
||||
absolute_path(&skill_dir.join("data")),
|
||||
absolute_path(&skill_dir.join("scripts/../data")),
|
||||
]),
|
||||
write: Some(vec![absolute_path(&skill_dir.join("output"))]),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("profile");
|
||||
|
||||
assert_eq!(
|
||||
profile,
|
||||
Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![
|
||||
AbsolutePathBuf::try_from(skill_dir.join("output"))
|
||||
.expect("absolute output path")
|
||||
],
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![
|
||||
AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(&read_dir).unwrap_or(read_dir)
|
||||
)
|
||||
.expect("absolute read path")
|
||||
],
|
||||
},
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
}),
|
||||
network: None,
|
||||
allow_login_shell: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: Some(
|
||||
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(),
|
||||
),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_permission_profile_without_permissions_has_empty_profile() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir");
|
||||
|
||||
let profile = compile_permission_profile(None);
|
||||
|
||||
assert_eq!(profile, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_permission_profile_with_network_only_uses_read_only_policy() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir");
|
||||
|
||||
let profile = compile_permission_profile(Some(PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("profile");
|
||||
|
||||
assert_eq!(
|
||||
profile,
|
||||
Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::FullAccess,
|
||||
network_access: true,
|
||||
}),
|
||||
network: None,
|
||||
allow_login_shell: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: Some(
|
||||
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(),
|
||||
),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_permission_profile_with_network_and_read_only_paths_uses_read_only_policy() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
let read_dir = skill_dir.join("data");
|
||||
fs::create_dir_all(&read_dir).expect("read dir");
|
||||
|
||||
let profile = compile_permission_profile(Some(PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![absolute_path(&skill_dir.join("data"))]),
|
||||
write: Some(Vec::new()),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("profile");
|
||||
|
||||
assert_eq!(
|
||||
profile,
|
||||
Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![
|
||||
AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(&read_dir).unwrap_or(read_dir)
|
||||
)
|
||||
.expect("absolute read path")
|
||||
],
|
||||
},
|
||||
network_access: true,
|
||||
}),
|
||||
network: None,
|
||||
allow_login_shell: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: Some(
|
||||
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(),
|
||||
),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn compile_permission_profile_builds_macos_permission_file() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir");
|
||||
|
||||
let profile = compile_permission_profile(Some(PermissionProfile {
|
||||
macos: Some(MacOsPermissions {
|
||||
preferences: Some(MacOsPreferencesValue::Mode("readwrite".to_string())),
|
||||
automations: Some(MacOsAutomationValue::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
])),
|
||||
accessibility: Some(true),
|
||||
calendar: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("profile");
|
||||
|
||||
assert_eq!(
|
||||
profile.macos_seatbelt_profile_extensions,
|
||||
Some(
|
||||
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences:
|
||||
crate::seatbelt_permissions::MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation:
|
||||
crate::seatbelt_permissions::MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string()
|
||||
],),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn compile_permission_profile_uses_macos_defaults_when_values_missing() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir");
|
||||
|
||||
let profile =
|
||||
compile_permission_profile(Some(PermissionProfile::default())).expect("profile");
|
||||
|
||||
assert_eq!(
|
||||
profile.macos_seatbelt_profile_extensions,
|
||||
Some(crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_artifacts::ArtifactBuildRequest;
|
||||
use codex_artifacts::ArtifactCommandOutput;
|
||||
use codex_artifacts::ArtifactRuntimeError;
|
||||
use codex_artifacts::ArtifactRuntimePlatform;
|
||||
use codex_artifacts::ArtifactRuntimeManager;
|
||||
use codex_artifacts::ArtifactRuntimeManagerConfig;
|
||||
use codex_artifacts::ArtifactsClient;
|
||||
use codex_artifacts::ArtifactsError;
|
||||
use codex_artifacts::InstalledArtifactRuntime;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -31,7 +29,7 @@ use codex_protocol::models::FunctionCallOutputBody;
|
||||
|
||||
const ARTIFACTS_TOOL_NAME: &str = "artifacts";
|
||||
const ARTIFACTS_PRAGMA_PREFIXES: [&str; 2] = ["// codex-artifacts:", "// codex-artifact-tool:"];
|
||||
const PINNED_ARTIFACT_RUNTIME_VERSION: &str = "2.4.0";
|
||||
pub(crate) const PINNED_ARTIFACT_RUNTIME_VERSION: &str = "2.4.0";
|
||||
const DEFAULT_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub struct ArtifactsHandler;
|
||||
@@ -80,10 +78,9 @@ impl ToolHandler for ArtifactsHandler {
|
||||
}
|
||||
};
|
||||
|
||||
let runtime = resolve_preinstalled_runtime(&turn.config.codex_home)
|
||||
.await
|
||||
.map_err(artifacts_error)?;
|
||||
let client = ArtifactsClient::from_installed_runtime(runtime);
|
||||
let client = ArtifactsClient::from_runtime_manager(default_runtime_manager(
|
||||
turn.config.codex_home.clone(),
|
||||
));
|
||||
|
||||
let started_at = Instant::now();
|
||||
emit_exec_begin(session.as_ref(), turn.as_ref(), &call_id).await;
|
||||
@@ -122,31 +119,6 @@ impl ToolHandler for ArtifactsHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_preinstalled_runtime(
|
||||
codex_home: &Path,
|
||||
) -> Result<InstalledArtifactRuntime, ArtifactsError> {
|
||||
let platform = ArtifactRuntimePlatform::detect_current()
|
||||
.map_err(ArtifactRuntimeError::from)
|
||||
.map_err(ArtifactsError::Runtime)?;
|
||||
let install_dir = codex_home
|
||||
.join("packages")
|
||||
.join("artifacts")
|
||||
.join(PINNED_ARTIFACT_RUNTIME_VERSION)
|
||||
.join(platform.as_str());
|
||||
if !install_dir.exists() {
|
||||
return Err(ArtifactsError::Io {
|
||||
context: format!(
|
||||
"artifact runtime {} is not installed at {}",
|
||||
PINNED_ARTIFACT_RUNTIME_VERSION,
|
||||
install_dir.display()
|
||||
),
|
||||
source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing artifact runtime"),
|
||||
});
|
||||
}
|
||||
|
||||
InstalledArtifactRuntime::load(install_dir, platform).map_err(ArtifactsError::Runtime)
|
||||
}
|
||||
|
||||
fn parse_freeform_args(input: &str) -> Result<ArtifactsToolArgs, FunctionCallError> {
|
||||
if input.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
@@ -240,8 +212,11 @@ fn parse_pragma_prefix(line: &str) -> Option<&str> {
|
||||
.find_map(|prefix| line.strip_prefix(prefix))
|
||||
}
|
||||
|
||||
fn artifacts_error(error: ArtifactsError) -> FunctionCallError {
|
||||
FunctionCallError::RespondToModel(error.to_string())
|
||||
fn default_runtime_manager(codex_home: std::path::PathBuf) -> ArtifactRuntimeManager {
|
||||
ArtifactRuntimeManager::new(ArtifactRuntimeManagerConfig::with_default_release(
|
||||
codex_home,
|
||||
PINNED_ARTIFACT_RUNTIME_VERSION,
|
||||
))
|
||||
}
|
||||
|
||||
async fn emit_exec_begin(session: &Session, turn: &TurnContext, call_id: &str) {
|
||||
@@ -357,10 +332,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_preinstalled_runtime_reads_pinned_cache_path() {
|
||||
#[test]
|
||||
fn default_runtime_manager_uses_openai_codex_release_base() {
|
||||
let codex_home = TempDir::new().expect("create temp codex home");
|
||||
let platform = ArtifactRuntimePlatform::detect_current().expect("detect platform");
|
||||
let manager = default_runtime_manager(codex_home.path().to_path_buf());
|
||||
|
||||
assert_eq!(
|
||||
manager.config().release().base_url().as_str(),
|
||||
"https://github.com/openai/codex/releases/download/"
|
||||
);
|
||||
assert_eq!(
|
||||
manager.config().release().runtime_version(),
|
||||
PINNED_ARTIFACT_RUNTIME_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cached_runtime_reads_pinned_cache_path() {
|
||||
let codex_home = TempDir::new().expect("create temp codex home");
|
||||
let platform =
|
||||
codex_artifacts::ArtifactRuntimePlatform::detect_current().expect("detect platform");
|
||||
let install_dir = codex_home
|
||||
.path()
|
||||
.join("packages")
|
||||
@@ -382,10 +373,28 @@ mod tests {
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write manifest");
|
||||
std::fs::create_dir_all(install_dir.join("artifact-tool/dist"))
|
||||
.expect("create build entrypoint dir");
|
||||
std::fs::create_dir_all(install_dir.join("granola-render/dist"))
|
||||
.expect("create render entrypoint dir");
|
||||
std::fs::write(
|
||||
install_dir.join("artifact-tool/dist/artifact_tool.mjs"),
|
||||
"export const ok = true;\n",
|
||||
)
|
||||
.expect("write build entrypoint");
|
||||
std::fs::write(
|
||||
install_dir.join("granola-render/dist/render_cli.mjs"),
|
||||
"export const ok = true;\n",
|
||||
)
|
||||
.expect("write render entrypoint");
|
||||
|
||||
let runtime = resolve_preinstalled_runtime(codex_home.path())
|
||||
.await
|
||||
.expect("resolve runtime");
|
||||
let runtime = codex_artifacts::load_cached_runtime(
|
||||
&codex_home
|
||||
.path()
|
||||
.join(codex_artifacts::DEFAULT_CACHE_ROOT_RELATIVE),
|
||||
PINNED_ARTIFACT_RUNTIME_VERSION,
|
||||
)
|
||||
.expect("resolve runtime");
|
||||
assert_eq!(runtime.runtime_version(), PINNED_ARTIFACT_RUNTIME_VERSION);
|
||||
assert_eq!(
|
||||
runtime.manifest().entrypoints,
|
||||
|
||||
@@ -268,6 +268,7 @@ mod tests {
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: enabled,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +296,7 @@ mod tests {
|
||||
},
|
||||
connector_id: connector_id.map(str::to_string),
|
||||
connector_name: connector_id.map(str::to_string),
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ context.btoa = (data) => Buffer.from(data, "binary").toString("base64");
|
||||
|
||||
// REPL state model:
|
||||
// - Every exec is compiled as a fresh ESM "cell".
|
||||
// - `previousModule` is the most recently evaluated module namespace.
|
||||
// - `previousModule` is the most recently committed module namespace.
|
||||
// - `previousBindings` tracks which top-level names should be carried forward.
|
||||
// Each new cell imports a synthetic view of the previous namespace and
|
||||
// redeclares those names so user variables behave like a persistent REPL.
|
||||
@@ -86,6 +86,12 @@ let previousModule = null;
|
||||
/** @type {Binding[]} */
|
||||
let previousBindings = [];
|
||||
let cellCounter = 0;
|
||||
let internalBindingCounter = 0;
|
||||
const internalBindingSalt = (() => {
|
||||
const raw = process.env.CODEX_THREAD_ID ?? "";
|
||||
const sanitized = raw.replace(/[^A-Za-z0-9_$]/g, "_");
|
||||
return sanitized || "session";
|
||||
})();
|
||||
let activeExecId = null;
|
||||
let fatalExitScheduled = false;
|
||||
|
||||
@@ -553,17 +559,447 @@ function collectBindings(ast) {
|
||||
return Array.from(map.entries()).map(([name, kind]) => ({ name, kind }));
|
||||
}
|
||||
|
||||
function collectPatternBindingNames(pattern) {
|
||||
const map = new Map();
|
||||
collectPatternNames(pattern, "binding", map);
|
||||
return Array.from(map.keys());
|
||||
}
|
||||
|
||||
function nextInternalBindingName() {
|
||||
// We intentionally do not scan user-declared names here. Internal helpers use
|
||||
// a per-thread salt plus a counter instead. A user could still collide by
|
||||
// deliberately spelling the exact generated name, but the thread-id salt
|
||||
// keeps accidental collisions negligible while avoiding more AST bookkeeping.
|
||||
return `__codex_internal_commit_${internalBindingSalt}_${internalBindingCounter++}`;
|
||||
}
|
||||
|
||||
function buildMarkCommittedExpression(names, markCommittedFnName) {
|
||||
const serializedNames = names.map((name) => JSON.stringify(name)).join(", ");
|
||||
return `(${markCommittedFnName}(${serializedNames}), undefined)`;
|
||||
}
|
||||
|
||||
function tryReadBindingValue(module, bindingName) {
|
||||
if (!module) {
|
||||
return { ok: false, value: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
return { ok: true, value: module.namespace[bindingName] };
|
||||
} catch {
|
||||
return { ok: false, value: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
function instrumentVariableDeclarationSource(
|
||||
code,
|
||||
declaration,
|
||||
markCommittedFnName,
|
||||
) {
|
||||
if (!declaration.declarations?.length) {
|
||||
return code.slice(declaration.start, declaration.end);
|
||||
}
|
||||
|
||||
const prefix = code.slice(declaration.start, declaration.declarations[0].start);
|
||||
const suffix = code.slice(
|
||||
declaration.declarations[declaration.declarations.length - 1].end,
|
||||
declaration.end,
|
||||
);
|
||||
const parts = [];
|
||||
|
||||
for (const decl of declaration.declarations) {
|
||||
parts.push(code.slice(decl.start, decl.end));
|
||||
|
||||
const names = collectPatternBindingNames(decl.id);
|
||||
if (names.length > 0) {
|
||||
const helperName = nextInternalBindingName();
|
||||
parts.push(
|
||||
`${helperName} = ${buildMarkCommittedExpression(names, markCommittedFnName)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return `${prefix}${parts.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
function instrumentLoopBody(code, body, names, guardName, markCommittedFnName) {
|
||||
const marker = `if (${guardName}) { ${guardName} = false; ${markCommittedFnName}(${names
|
||||
.map((name) => JSON.stringify(name))
|
||||
.join(", ")}); }`;
|
||||
const bodyCode = code.slice(body.start, body.end);
|
||||
|
||||
if (body.type === "BlockStatement") {
|
||||
return `{ ${marker}${bodyCode.slice(1)}`;
|
||||
}
|
||||
|
||||
return `{ ${marker} ${bodyCode} }`;
|
||||
}
|
||||
|
||||
function applyReplacements(code, replacements) {
|
||||
let instrumentedCode = code;
|
||||
|
||||
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
|
||||
instrumentedCode =
|
||||
instrumentedCode.slice(0, replacement.start) +
|
||||
replacement.text +
|
||||
instrumentedCode.slice(replacement.end);
|
||||
}
|
||||
|
||||
return instrumentedCode;
|
||||
}
|
||||
|
||||
function collectHoistedVarDeclarationStarts(ast) {
|
||||
const varDeclarationStarts = new Map();
|
||||
|
||||
const recordDeclarationStart = (map, name, start) => {
|
||||
const existingStart = map.get(name);
|
||||
if (existingStart === undefined || start < existingStart) {
|
||||
map.set(name, start);
|
||||
}
|
||||
};
|
||||
|
||||
const recordVarDeclarationStarts = (declaration) => {
|
||||
for (const name of collectPatternBindingNames(declaration.id)) {
|
||||
recordDeclarationStart(varDeclarationStarts, name, declaration.start);
|
||||
}
|
||||
};
|
||||
|
||||
for (const stmt of ast.body ?? []) {
|
||||
if (stmt.type === "VariableDeclaration" && stmt.kind === "var") {
|
||||
for (const declaration of stmt.declarations ?? []) {
|
||||
recordVarDeclarationStarts(declaration);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
stmt.type === "ForStatement" &&
|
||||
stmt.init?.type === "VariableDeclaration" &&
|
||||
stmt.init.kind === "var"
|
||||
) {
|
||||
for (const declaration of stmt.init.declarations ?? []) {
|
||||
recordVarDeclarationStarts(declaration);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(stmt.type === "ForInStatement" || stmt.type === "ForOfStatement") &&
|
||||
stmt.left?.type === "VariableDeclaration" &&
|
||||
stmt.left.kind === "var"
|
||||
) {
|
||||
for (const declaration of stmt.left.declarations ?? []) {
|
||||
recordVarDeclarationStarts(declaration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return varDeclarationStarts;
|
||||
}
|
||||
|
||||
function collectFutureVarWriteReplacements(
|
||||
code,
|
||||
ast,
|
||||
{
|
||||
helperDeclarations = null,
|
||||
markCommittedFnName = null,
|
||||
} = {},
|
||||
) {
|
||||
// Failed-cell hoisted tracking intentionally stays small here. We only mark
|
||||
// direct top-level writes to future `var` bindings, plus top-level
|
||||
// declaration-site markers handled later in `instrumentCurrentBindings`.
|
||||
// We do not recurse through nested statement structure because that quickly
|
||||
// requires real lexical-scope tracking for blocks, loop scopes, catch
|
||||
// bindings, and similar shadowing cases. Supported write recovery is limited
|
||||
// to direct top-level expression statements such as `x = 1`, `x += 1`,
|
||||
// `x++`, and logical assignments.
|
||||
const varDeclarationStarts = collectHoistedVarDeclarationStarts(ast);
|
||||
if (varDeclarationStarts.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const replacements = [];
|
||||
const replacementKeys = new Set();
|
||||
|
||||
if (!markCommittedFnName) {
|
||||
throw new Error(
|
||||
"collectFutureVarWriteReplacements expected a commit marker binding name",
|
||||
);
|
||||
}
|
||||
|
||||
const addReplacement = (start, end, text) => {
|
||||
const key = `${start}:${end}`;
|
||||
if (!replacementKeys.has(key)) {
|
||||
replacementKeys.add(key);
|
||||
replacements.push({ start, end, text });
|
||||
}
|
||||
};
|
||||
|
||||
const getFutureVarName = (identifier) => {
|
||||
if (!identifier || identifier.type !== "Identifier") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const declarationStart = varDeclarationStarts.get(identifier.name);
|
||||
if (
|
||||
declarationStart === undefined ||
|
||||
identifier.start >= declarationStart
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return identifier.name;
|
||||
};
|
||||
|
||||
const instrumentUpdateExpression = (node, identifier) => {
|
||||
const bindingName = getFutureVarName(identifier);
|
||||
if (!bindingName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
addReplacement(
|
||||
node.start,
|
||||
node.end,
|
||||
`(${markCommittedFnName}(${JSON.stringify(bindingName)}), ${code.slice(
|
||||
node.start,
|
||||
node.end,
|
||||
)})`,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const instrumentAssignmentExpression = (node) => {
|
||||
if (node.left.type !== "Identifier") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bindingName = getFutureVarName(node.left);
|
||||
if (!bindingName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
node.operator === "&&=" ||
|
||||
node.operator === "||=" ||
|
||||
node.operator === "??="
|
||||
) {
|
||||
if (!helperDeclarations) {
|
||||
throw new Error(
|
||||
"collectFutureVarWriteReplacements expected helperDeclarations for logical assignment rewriting",
|
||||
);
|
||||
}
|
||||
|
||||
const helperName = nextInternalBindingName();
|
||||
helperDeclarations.push(`let ${helperName};`);
|
||||
const shortCircuitOperator =
|
||||
node.operator === "&&="
|
||||
? "&&"
|
||||
: node.operator === "||="
|
||||
? "||"
|
||||
: "??";
|
||||
addReplacement(
|
||||
node.start,
|
||||
node.end,
|
||||
`((${helperName} = ${node.left.name}), ${helperName} ${shortCircuitOperator} ((${node.left.name} = ${code.slice(node.right.start, node.right.end)}), ${buildMarkCommittedExpression([bindingName], markCommittedFnName)}, ${node.left.name}))`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
addReplacement(
|
||||
node.start,
|
||||
node.end,
|
||||
`((${code.slice(node.start, node.end)}), ${buildMarkCommittedExpression([bindingName], markCommittedFnName)}, ${node.left.name})`,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const unwrapParenthesizedExpression = (node) => {
|
||||
let current = node;
|
||||
while (current?.type === "ParenthesizedExpression") {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
for (const statement of ast.body ?? []) {
|
||||
if (statement.type !== "ExpressionStatement") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const expression = unwrapParenthesizedExpression(statement.expression);
|
||||
if (!expression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
expression.type === "UpdateExpression" &&
|
||||
expression.argument.type === "Identifier"
|
||||
) {
|
||||
instrumentUpdateExpression(expression, expression.argument);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.type === "AssignmentExpression") {
|
||||
instrumentAssignmentExpression(expression);
|
||||
}
|
||||
}
|
||||
|
||||
return replacements;
|
||||
}
|
||||
|
||||
function instrumentCurrentBindings(
|
||||
code,
|
||||
ast,
|
||||
currentBindings,
|
||||
priorBindings,
|
||||
markCommittedFnName,
|
||||
) {
|
||||
if (currentBindings.length === 0) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const replacements = [];
|
||||
|
||||
for (const stmt of ast.body ?? []) {
|
||||
if (stmt.type === "VariableDeclaration") {
|
||||
replacements.push({
|
||||
start: stmt.start,
|
||||
end: stmt.end,
|
||||
text: instrumentVariableDeclarationSource(
|
||||
code,
|
||||
stmt,
|
||||
markCommittedFnName,
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stmt.type === "FunctionDeclaration" && stmt.id) {
|
||||
replacements.push({
|
||||
start: stmt.start,
|
||||
end: stmt.end,
|
||||
// Keep function source text stable for things like `foo.toString()`.
|
||||
// Pre-declaration uses are tracked separately by instrumenting the
|
||||
// top-level expressions that actually read the hoisted function value.
|
||||
text: `${code.slice(stmt.start, stmt.end)}\n;${markCommittedFnName}(${JSON.stringify(stmt.id.name)});`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stmt.type === "ClassDeclaration" && stmt.id) {
|
||||
replacements.push({
|
||||
start: stmt.start,
|
||||
end: stmt.end,
|
||||
text: `${code.slice(stmt.start, stmt.end)}\n;${markCommittedFnName}(${JSON.stringify(stmt.id.name)});`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
stmt.type === "ForStatement" &&
|
||||
stmt.init &&
|
||||
stmt.init.type === "VariableDeclaration" &&
|
||||
stmt.init.kind === "var"
|
||||
) {
|
||||
replacements.push({
|
||||
start: stmt.start,
|
||||
end: stmt.end,
|
||||
text: `${code.slice(stmt.start, stmt.init.start)}${instrumentVariableDeclarationSource(
|
||||
code,
|
||||
stmt.init,
|
||||
markCommittedFnName,
|
||||
)}${code.slice(stmt.init.end, stmt.end)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(stmt.type === "ForInStatement" || stmt.type === "ForOfStatement") &&
|
||||
stmt.left &&
|
||||
stmt.left.type === "VariableDeclaration" &&
|
||||
stmt.left.kind === "var"
|
||||
) {
|
||||
const names = stmt.left.declarations.flatMap((decl) =>
|
||||
collectPatternBindingNames(decl.id),
|
||||
);
|
||||
if (names.length > 0) {
|
||||
const guardName = nextInternalBindingName();
|
||||
replacements.push({
|
||||
start: stmt.start,
|
||||
end: stmt.end,
|
||||
// Mark top-level `for...in` / `for...of` vars on the first body
|
||||
// execution instead of every iteration. This keeps hot loops cheap
|
||||
// after the first pass while still preserving vars for the common
|
||||
// case where the loop actually ran before a later throw.
|
||||
//
|
||||
// The tradeoff is that `for (var x of []) {}` in a failed cell will
|
||||
// not carry `x` forward as `undefined`, because the body never runs
|
||||
// and the one-time marker never fires. We accept that edge case:
|
||||
// `var` is redeclarable, and the only lost state is an unassigned
|
||||
// `undefined` from an empty top-level loop in a cell that later
|
||||
// fails.
|
||||
text: `let ${guardName} = true;\n${code.slice(
|
||||
stmt.start,
|
||||
stmt.body.start,
|
||||
)}${instrumentLoopBody(
|
||||
code,
|
||||
stmt.body,
|
||||
names,
|
||||
guardName,
|
||||
markCommittedFnName,
|
||||
)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return applyReplacements(code, replacements);
|
||||
}
|
||||
|
||||
async function buildModuleSource(code) {
|
||||
const meriyah = await meriyahPromise;
|
||||
const ast = meriyah.parseModule(code, {
|
||||
next: true,
|
||||
module: true,
|
||||
ranges: false,
|
||||
ranges: true,
|
||||
loc: false,
|
||||
disableWebCompat: true,
|
||||
});
|
||||
const currentBindings = collectBindings(ast);
|
||||
const priorBindings = previousModule ? previousBindings : [];
|
||||
const helperDeclarations = [];
|
||||
const markCommittedFnName = nextInternalBindingName();
|
||||
const markPreludeCompletedFnName = nextInternalBindingName();
|
||||
helperDeclarations.push(
|
||||
// `import.meta` is syntax-level and cannot be shadowed by user bindings
|
||||
// like `const globalThis = ...`, so alias the marker helper through it
|
||||
// once in the prelude and use that stable local binding everywhere.
|
||||
// Then delete the raw import.meta hooks so user code cannot spoof
|
||||
// committed bindings by calling them directly.
|
||||
`const ${markCommittedFnName} = import.meta.__codexInternalMarkCommittedBindings;`,
|
||||
`const ${markPreludeCompletedFnName} = import.meta.__codexInternalMarkPreludeCompleted;`,
|
||||
"delete import.meta.__codexInternalMarkCommittedBindings;",
|
||||
"delete import.meta.__codexInternalMarkPreludeCompleted;",
|
||||
);
|
||||
const writeInstrumentedCode = applyReplacements(
|
||||
code,
|
||||
collectFutureVarWriteReplacements(code, ast, {
|
||||
helperDeclarations,
|
||||
markCommittedFnName,
|
||||
}),
|
||||
);
|
||||
const instrumentedAst = meriyah.parseModule(writeInstrumentedCode, {
|
||||
next: true,
|
||||
module: true,
|
||||
ranges: true,
|
||||
loc: false,
|
||||
disableWebCompat: true,
|
||||
});
|
||||
const instrumentedCode = instrumentCurrentBindings(
|
||||
writeInstrumentedCode,
|
||||
instrumentedAst,
|
||||
currentBindings,
|
||||
priorBindings,
|
||||
markCommittedFnName,
|
||||
);
|
||||
|
||||
let prelude = "";
|
||||
if (previousModule && priorBindings.length) {
|
||||
@@ -578,6 +1014,10 @@ async function buildModuleSource(code) {
|
||||
.join("\n");
|
||||
prelude += "\n";
|
||||
}
|
||||
if (helperDeclarations.length > 0) {
|
||||
prelude += `${helperDeclarations.join("\n")}\n`;
|
||||
}
|
||||
prelude += `${markPreludeCompletedFnName}();\n`;
|
||||
|
||||
const mergedBindings = new Map();
|
||||
for (const binding of priorBindings) {
|
||||
@@ -596,7 +1036,60 @@ async function buildModuleSource(code) {
|
||||
name,
|
||||
kind,
|
||||
}));
|
||||
return { source: `${prelude}${code}${exportStmt}`, nextBindings };
|
||||
return {
|
||||
source: `${prelude}${instrumentedCode}${exportStmt}`,
|
||||
currentBindings,
|
||||
nextBindings,
|
||||
priorBindings,
|
||||
};
|
||||
}
|
||||
|
||||
function canReadCommittedBinding(module, binding) {
|
||||
if (
|
||||
!module ||
|
||||
binding.kind === "var" ||
|
||||
binding.kind === "function"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return tryReadBindingValue(module, binding.name).ok;
|
||||
}
|
||||
// Failed cells keep prior bindings plus the current-cell bindings whose
|
||||
// initialization definitely ran before the throw. That means:
|
||||
// - lexical bindings (`const` / `let` / `class`) can fall back to namespace
|
||||
// readability, which preserves names whose initialization already completed
|
||||
// even when a later step in the same declarator throws
|
||||
// - `var` / `function` bindings only persist when an explicit declaration-site
|
||||
// or write-site marker fired, so unreached hoisted bindings do not become
|
||||
// ghost bindings in later cells
|
||||
function collectCommittedBindings(
|
||||
module,
|
||||
priorBindings,
|
||||
currentBindings,
|
||||
committedCurrentBindingNames,
|
||||
) {
|
||||
const mergedBindings = new Map();
|
||||
let committedCurrentBindingCount = 0;
|
||||
|
||||
for (const binding of priorBindings) {
|
||||
mergedBindings.set(binding.name, binding.kind);
|
||||
}
|
||||
|
||||
for (const binding of currentBindings) {
|
||||
if (
|
||||
committedCurrentBindingNames.has(binding.name) ||
|
||||
canReadCommittedBinding(module, binding)
|
||||
) {
|
||||
mergedBindings.set(binding.name, binding.kind);
|
||||
committedCurrentBindingCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bindings: Array.from(mergedBindings, ([name, kind]) => ({ name, kind })),
|
||||
committedCurrentBindingCount,
|
||||
};
|
||||
}
|
||||
|
||||
function send(message) {
|
||||
@@ -735,15 +1228,27 @@ function parseImageDetail(detail) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
function normalizeEmitImageUrl(value) {
|
||||
if (typeof value !== "string" || !value) {
|
||||
throw new Error("codex.emitImage expected a non-empty image_url");
|
||||
}
|
||||
if (!/^data:/i.test(value)) {
|
||||
throw new Error("codex.emitImage only accepts data URLs");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseInputImageItem(value) {
|
||||
if (!isPlainObject(value) || value.type !== "input_image") {
|
||||
return null;
|
||||
}
|
||||
if (typeof value.image_url !== "string" || !value.image_url) {
|
||||
throw new Error("codex.emitImage expected a non-empty image_url");
|
||||
}
|
||||
return {
|
||||
images: [{ image_url: value.image_url, detail: parseImageDetail(value.detail) }],
|
||||
images: [
|
||||
{
|
||||
image_url: normalizeEmitImageUrl(value.image_url),
|
||||
detail: parseImageDetail(value.detail),
|
||||
},
|
||||
],
|
||||
textCount: 0,
|
||||
};
|
||||
}
|
||||
@@ -760,11 +1265,8 @@ function parseContentItems(items) {
|
||||
throw new Error("codex.emitImage received malformed content items");
|
||||
}
|
||||
if (item.type === "input_image") {
|
||||
if (typeof item.image_url !== "string" || !item.image_url) {
|
||||
throw new Error("codex.emitImage expected a non-empty image_url");
|
||||
}
|
||||
images.push({
|
||||
image_url: item.image_url,
|
||||
image_url: normalizeEmitImageUrl(item.image_url),
|
||||
detail: parseImageDetail(item.detail),
|
||||
});
|
||||
continue;
|
||||
@@ -815,7 +1317,7 @@ function normalizeMcpImageData(data, mimeType) {
|
||||
if (typeof data !== "string" || !data) {
|
||||
throw new Error("codex.emitImage expected MCP image data");
|
||||
}
|
||||
if (data.startsWith("data:")) {
|
||||
if (/^data:/i.test(data)) {
|
||||
return data;
|
||||
}
|
||||
const normalizedMimeType =
|
||||
@@ -882,10 +1384,7 @@ function requireSingleImage(parsed) {
|
||||
|
||||
function normalizeEmitImageValue(value) {
|
||||
if (typeof value === "string") {
|
||||
if (!value) {
|
||||
throw new Error("codex.emitImage expected a non-empty image URL");
|
||||
}
|
||||
return { image_url: value };
|
||||
return { image_url: normalizeEmitImageUrl(value) };
|
||||
}
|
||||
|
||||
const directItem = parseInputImageItem(value);
|
||||
@@ -1012,9 +1511,32 @@ async function handleExec(message) {
|
||||
};
|
||||
};
|
||||
|
||||
let module = null;
|
||||
/** @type {Binding[]} */
|
||||
let currentBindings = [];
|
||||
/** @type {Binding[]} */
|
||||
let nextBindings = [];
|
||||
/** @type {Binding[]} */
|
||||
let priorBindings = previousBindings;
|
||||
let moduleLinked = false;
|
||||
let preludeCompleted = false;
|
||||
const committedCurrentBindingNames = new Set();
|
||||
const markCommittedBindings = (...names) => {
|
||||
for (const name of names) {
|
||||
committedCurrentBindingNames.add(name);
|
||||
}
|
||||
};
|
||||
const markPreludeCompleted = () => {
|
||||
preludeCompleted = true;
|
||||
};
|
||||
|
||||
try {
|
||||
const code = typeof message.code === "string" ? message.code : "";
|
||||
const { source, nextBindings } = await buildModuleSource(code);
|
||||
const builtSource = await buildModuleSource(code);
|
||||
const source = builtSource.source;
|
||||
currentBindings = builtSource.currentBindings;
|
||||
nextBindings = builtSource.nextBindings;
|
||||
priorBindings = builtSource.priorBindings;
|
||||
let output = "";
|
||||
|
||||
context.codex = { tmpDir, tool, emitImage };
|
||||
@@ -1025,11 +1547,13 @@ async function handleExec(message) {
|
||||
process.cwd(),
|
||||
`.codex_js_repl_cell_${cellCounter++}.mjs`,
|
||||
);
|
||||
const module = new SourceTextModule(source, {
|
||||
module = new SourceTextModule(source, {
|
||||
context,
|
||||
identifier: cellIdentifier,
|
||||
initializeImportMeta(meta, mod) {
|
||||
setImportMeta(meta, mod, true);
|
||||
meta.__codexInternalMarkCommittedBindings = markCommittedBindings;
|
||||
meta.__codexInternalMarkPreludeCompleted = markPreludeCompleted;
|
||||
},
|
||||
importModuleDynamically(specifier, referrer) {
|
||||
return importResolved(resolveSpecifier(specifier, referrer?.identifier));
|
||||
@@ -1059,6 +1583,7 @@ async function handleExec(message) {
|
||||
`Top-level static import "${specifier}" is not supported in js_repl. Use await import("${specifier}") instead.`,
|
||||
);
|
||||
});
|
||||
moduleLinked = true;
|
||||
|
||||
await module.evaluate();
|
||||
if (pendingBackgroundTasks.size > 0) {
|
||||
@@ -1070,11 +1595,12 @@ async function handleExec(message) {
|
||||
throw firstUnhandledBackgroundError.error;
|
||||
}
|
||||
}
|
||||
previousModule = module;
|
||||
previousBindings = nextBindings;
|
||||
output = logs.join("\n");
|
||||
});
|
||||
|
||||
previousModule = module;
|
||||
previousBindings = nextBindings;
|
||||
|
||||
send({
|
||||
type: "exec_result",
|
||||
id: message.id,
|
||||
@@ -1083,6 +1609,29 @@ async function handleExec(message) {
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const { bindings: committedBindings, committedCurrentBindingCount } =
|
||||
collectCommittedBindings(
|
||||
moduleLinked ? module : null,
|
||||
priorBindings,
|
||||
currentBindings,
|
||||
committedCurrentBindingNames,
|
||||
);
|
||||
// Preserve the last successfully linked module across link-time failures.
|
||||
// A module whose link step failed cannot safely back @prev because reading
|
||||
// its namespace throws before evaluation ever begins. Likewise, if a
|
||||
// linked module failed before its prelude recreated carried bindings, keep
|
||||
// the old module so @prev still points at the last cell whose prelude and
|
||||
// body actually established the carried values. Once the prelude has run,
|
||||
// promote the failed module even if it only updated existing bindings.
|
||||
if (
|
||||
module &&
|
||||
moduleLinked &&
|
||||
(committedCurrentBindingCount > 0 ||
|
||||
(preludeCompleted && priorBindings.length > 0))
|
||||
) {
|
||||
previousModule = module;
|
||||
previousBindings = committedBindings;
|
||||
}
|
||||
send({
|
||||
type: "exec_result",
|
||||
id: message.id,
|
||||
|
||||
@@ -1134,22 +1134,31 @@ impl JsReplManager {
|
||||
let emit_id = req.id.clone();
|
||||
let response =
|
||||
if let Some(ctx) = exec_contexts.lock().await.get(&exec_id).cloned() {
|
||||
let content_item = emitted_image_content_item(
|
||||
ctx.turn.as_ref(),
|
||||
req.image_url,
|
||||
req.detail,
|
||||
);
|
||||
JsReplManager::record_exec_content_item(
|
||||
&exec_tool_calls,
|
||||
&exec_id,
|
||||
content_item,
|
||||
)
|
||||
.await;
|
||||
HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
ok: true,
|
||||
error: None,
|
||||
})
|
||||
match validate_emitted_image_url(&req.image_url) {
|
||||
Ok(()) => {
|
||||
let content_item = emitted_image_content_item(
|
||||
ctx.turn.as_ref(),
|
||||
req.image_url,
|
||||
req.detail,
|
||||
);
|
||||
JsReplManager::record_exec_content_item(
|
||||
&exec_tool_calls,
|
||||
&exec_id,
|
||||
content_item,
|
||||
)
|
||||
.await;
|
||||
HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
ok: true,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(error) => HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
ok: false,
|
||||
error: Some(error),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
@@ -1467,6 +1476,17 @@ fn emitted_image_content_item(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_emitted_image_url(image_url: &str) -> Result<(), String> {
|
||||
if image_url
|
||||
.get(..5)
|
||||
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("data:"))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err("codex.emitImage only accepts data URLs".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_output_image_detail_for_turn(turn: &TurnContext) -> Option<ImageDetail> {
|
||||
(turn.config.features.enabled(Feature::ImageDetailOriginal)
|
||||
&& turn.model_info.supports_image_detail_original)
|
||||
@@ -2005,6 +2025,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_emitted_image_url_accepts_case_insensitive_data_scheme() {
|
||||
assert_eq!(
|
||||
validate_emitted_image_url("DATA:image/png;base64,AAA"),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_emitted_image_url_rejects_non_data_scheme() {
|
||||
assert_eq!(
|
||||
validate_emitted_image_url("https://example.com/image.png"),
|
||||
Err("codex.emitImage only accepts data URLs".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_tool_call_response_for_multimodal_custom_output() {
|
||||
let response = ResponseInputItem::CustomToolCallOutput {
|
||||
@@ -2124,7 +2160,11 @@ mod tests {
|
||||
// integration tests instead.
|
||||
cfg!(target_os = "macos")
|
||||
}
|
||||
fn write_js_repl_test_package(base: &Path, name: &str, value: &str) -> anyhow::Result<()> {
|
||||
fn write_js_repl_test_package_source(
|
||||
base: &Path,
|
||||
name: &str,
|
||||
source: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let pkg_dir = base.join("node_modules").join(name);
|
||||
fs::create_dir_all(&pkg_dir)?;
|
||||
fs::write(
|
||||
@@ -2133,9 +2173,15 @@ mod tests {
|
||||
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"exports\": {{\n \"import\": \"./index.js\"\n }}\n}}\n"
|
||||
),
|
||||
)?;
|
||||
fs::write(
|
||||
pkg_dir.join("index.js"),
|
||||
format!("export const value = \"{value}\";\n"),
|
||||
fs::write(pkg_dir.join("index.js"), source)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_js_repl_test_package(base: &Path, name: &str, value: &str) -> anyhow::Result<()> {
|
||||
write_js_repl_test_package_source(
|
||||
base,
|
||||
name,
|
||||
&format!("export const value = \"{value}\";\n"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2895,6 +2941,98 @@ await codex.emitImage({ bytes: png });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_emit_image_rejects_non_data_url() -> anyhow::Result<()> {
|
||||
if !can_run_js_repl_runtime_tests().await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
if !turn
|
||||
.model_info
|
||||
.input_modalities
|
||||
.contains(&InputModality::Image)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
*session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
|
||||
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
|
||||
let manager = turn.js_repl.manager().await?;
|
||||
let code = r#"
|
||||
await codex.emitImage("https://example.com/image.png");
|
||||
"#;
|
||||
|
||||
let err = manager
|
||||
.execute(
|
||||
Arc::clone(&session),
|
||||
turn,
|
||||
tracker,
|
||||
JsReplArgs {
|
||||
code: code.to_string(),
|
||||
timeout_ms: Some(15_000),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("non-data URLs should fail");
|
||||
assert!(err.to_string().contains("only accepts data URLs"));
|
||||
assert!(session.get_pending_input().await.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_emit_image_accepts_case_insensitive_data_url() -> anyhow::Result<()> {
|
||||
if !can_run_js_repl_runtime_tests().await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
if !turn
|
||||
.model_info
|
||||
.input_modalities
|
||||
.contains(&InputModality::Image)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
*session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
|
||||
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
|
||||
let manager = turn.js_repl.manager().await?;
|
||||
let code = r#"
|
||||
await codex.emitImage("DATA:image/png;base64,AAA");
|
||||
"#;
|
||||
|
||||
let result = manager
|
||||
.execute(
|
||||
Arc::clone(&session),
|
||||
turn,
|
||||
tracker,
|
||||
JsReplArgs {
|
||||
code: code.to_string(),
|
||||
timeout_ms: Some(15_000),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
result.content_items.as_slice(),
|
||||
[FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "DATA:image/png;base64,AAA".to_string(),
|
||||
detail: None,
|
||||
}]
|
||||
.as_slice()
|
||||
);
|
||||
assert!(session.get_pending_input().await.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_emit_image_rejects_invalid_detail() -> anyhow::Result<()> {
|
||||
if !can_run_js_repl_runtime_tests().await {
|
||||
|
||||
@@ -10,7 +10,6 @@ use crate::sandboxing::ExecRequest;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::ShellType;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::permissions::compile_permission_profile;
|
||||
use crate::tools::runtimes::ExecveSessionApproval;
|
||||
use crate::tools::runtimes::build_command_spec;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
@@ -335,18 +334,14 @@ impl CoreShellActionProvider {
|
||||
}
|
||||
|
||||
fn skill_escalation_execution(skill: &SkillMetadata) -> EscalationExecution {
|
||||
compile_permission_profile(skill.permission_profile.clone())
|
||||
.map(|permissions| {
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(
|
||||
EscalatedPermissions {
|
||||
sandbox_policy: permissions.sandbox_policy.get().clone(),
|
||||
macos_seatbelt_profile_extensions: permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.clone(),
|
||||
},
|
||||
))
|
||||
})
|
||||
.unwrap_or(EscalationExecution::TurnDefault)
|
||||
let permission_profile = skill.permission_profile.clone().unwrap_or_default();
|
||||
if permission_profile.is_empty() {
|
||||
EscalationExecution::TurnDefault
|
||||
} else {
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
permission_profile,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn prompt(
|
||||
@@ -742,11 +737,22 @@ struct CoreShellCommandExecutor {
|
||||
justification: Option<String>,
|
||||
arg0: Option<String>,
|
||||
sandbox_policy_cwd: PathBuf,
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
use_linux_sandbox_bwrap: bool,
|
||||
}
|
||||
|
||||
struct PrepareSandboxedExecParams<'a> {
|
||||
command: Vec<String>,
|
||||
workdir: &'a AbsolutePathBuf,
|
||||
env: HashMap<String, String>,
|
||||
sandbox_policy: &'a SandboxPolicy,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
async fn run(
|
||||
@@ -817,33 +823,46 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
env,
|
||||
arg0: Some(first_arg.clone()),
|
||||
},
|
||||
EscalationExecution::TurnDefault => self.prepare_sandboxed_exec(
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
&self.sandbox_policy,
|
||||
None,
|
||||
self.macos_seatbelt_profile_extensions.as_ref(),
|
||||
)?,
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
permission_profile,
|
||||
)) => self.prepare_sandboxed_exec(
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
&self.sandbox_policy,
|
||||
Some(permission_profile),
|
||||
None,
|
||||
)?,
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => {
|
||||
self.prepare_sandboxed_exec(
|
||||
EscalationExecution::TurnDefault => {
|
||||
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
&permissions.sandbox_policy,
|
||||
None,
|
||||
permissions.macos_seatbelt_profile_extensions.as_ref(),
|
||||
)?
|
||||
sandbox_policy: &self.sandbox_policy,
|
||||
additional_permissions: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: self
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
})?
|
||||
}
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
permission_profile,
|
||||
)) => {
|
||||
// Merge additive permissions into the existing turn/request sandbox policy.
|
||||
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
sandbox_policy: &self.sandbox_policy,
|
||||
additional_permissions: Some(permission_profile),
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
})?
|
||||
}
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => {
|
||||
// Use a fully specified sandbox policy instead of merging into the turn policy.
|
||||
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
sandbox_policy: &permissions.sandbox_policy,
|
||||
additional_permissions: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -854,18 +873,17 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
impl CoreShellCommandExecutor {
|
||||
fn prepare_sandboxed_exec(
|
||||
&self,
|
||||
command: Vec<String>,
|
||||
workdir: &AbsolutePathBuf,
|
||||
env: HashMap<String, String>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
#[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: Option<
|
||||
&MacOsSeatbeltProfileExtensions,
|
||||
>,
|
||||
#[cfg(not(target_os = "macos"))] _macos_seatbelt_profile_extensions: Option<
|
||||
&MacOsSeatbeltProfileExtensions,
|
||||
>,
|
||||
params: PrepareSandboxedExecParams<'_>,
|
||||
) -> anyhow::Result<PreparedExec> {
|
||||
let PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
sandbox_policy,
|
||||
additional_permissions,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions,
|
||||
} = params;
|
||||
let (program, args) = command
|
||||
.split_first()
|
||||
.ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?;
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::protocol::SandboxPolicy;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use crate::skills::SkillMetadata;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
@@ -30,6 +31,7 @@ use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::MacOsPreferencesPermission;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_shell_escalation::EscalationExecution;
|
||||
use codex_shell_escalation::EscalationPermissions;
|
||||
use codex_shell_escalation::ExecResult;
|
||||
@@ -59,6 +61,20 @@ fn starlark_string(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
fn test_skill_metadata(permission_profile: Option<PermissionProfile>) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: "skill".to_string(),
|
||||
description: "description".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
permission_profile,
|
||||
path_to_skills_md: PathBuf::from("/tmp/skill/SKILL.md"),
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_preserves_login_flag() {
|
||||
assert_eq!(
|
||||
@@ -246,6 +262,42 @@ fn shell_request_escalation_execution_is_explicit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_escalation_execution_uses_additional_permissions() {
|
||||
let requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![
|
||||
AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(),
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::skill_escalation_execution(&test_skill_metadata(Some(
|
||||
requested_permissions.clone(),
|
||||
))),
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
requested_permissions,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_escalation_execution_ignores_empty_permissions() {
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::skill_escalation_execution(&test_skill_metadata(Some(
|
||||
PermissionProfile::default(),
|
||||
))),
|
||||
EscalationExecution::TurnDefault,
|
||||
);
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::skill_escalation_execution(&test_skill_metadata(None)),
|
||||
EscalationExecution::TurnDefault,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_parsing_disabled() {
|
||||
let policy_src = r#"prefix_rule(pattern = ["npm", "publish"], decision = "prompt")"#;
|
||||
|
||||
@@ -98,7 +98,8 @@ impl ToolsConfig {
|
||||
let include_default_mode_request_user_input =
|
||||
include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput);
|
||||
let include_search_tool = features.enabled(Feature::Apps);
|
||||
let include_artifact_tools = features.enabled(Feature::Artifact);
|
||||
let include_artifact_tools =
|
||||
features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime();
|
||||
let include_image_gen_tool =
|
||||
features.enabled(Feature::ImageGeneration) && supports_image_generation(model_info);
|
||||
let include_agent_jobs = include_collab_tools && features.enabled(Feature::Sqlite);
|
||||
@@ -1405,7 +1406,7 @@ JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/
|
||||
|
||||
ToolSpec::Freeform(FreeformTool {
|
||||
name: "artifacts".to_string(),
|
||||
description: "Runs raw JavaScript against the preinstalled Codex @oai/artifact-tool runtime for creating presentations or spreadsheets. This is plain JavaScript executed by Node with top-level await, not TypeScript: do not use type annotations, `interface`, `type`, or `import type`. Author code the same way you would for `import { Presentation, Workbook, PresentationFile, SpreadsheetFile, FileBlob, ... } from \"@oai/artifact-tool\"`, but omit that import line because the package surface is already preloaded. Named exports are available directly on `globalThis`, and the full module is available as `globalThis.artifactTool` (also aliased as `globalThis.artifacts` and `globalThis.codexArtifacts`). Node built-ins such as `node:fs/promises` may still be imported when needed for saving preview bytes. This is a freeform tool: send raw JavaScript source text, optionally with a first-line pragma like `// codex-artifacts: timeout_ms=15000` or `// codex-artifact-tool: timeout_ms=15000`; do not send JSON/quotes/markdown fences."
|
||||
description: "Runs raw JavaScript against the preinstalled Codex @oai/artifact-tool runtime for creating presentations or spreadsheets. This is plain JavaScript executed by a local Node-compatible runtime with top-level await, not TypeScript: do not use type annotations, `interface`, `type`, or `import type`. Author code the same way you would for `import { Presentation, Workbook, PresentationFile, SpreadsheetFile, FileBlob, ... } from \"@oai/artifact-tool\"`, but omit that import line because the package surface is already preloaded. Named exports are available directly on `globalThis`, and the full module is available as `globalThis.artifactTool` (also aliased as `globalThis.artifacts` and `globalThis.codexArtifacts`). Node built-ins such as `node:fs/promises` may still be imported when needed for saving preview bytes. This is a freeform tool: send raw JavaScript source text, optionally with a first-line pragma like `// codex-artifacts: timeout_ms=15000` or `// codex-artifact-tool: timeout_ms=15000`; do not send JSON/quotes/markdown fences."
|
||||
.to_string(),
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
@@ -2249,7 +2250,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_artifact_tool_enabled() {
|
||||
let config = test_config();
|
||||
let mut config = test_config();
|
||||
let runtime_root = tempfile::TempDir::new().expect("create temp codex home");
|
||||
config.codex_home = runtime_root.path().to_path_buf();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
@@ -2466,6 +2469,7 @@ mod tests {
|
||||
web_search_mode: Option<WebSearchMode>,
|
||||
expected_tools: &[&str],
|
||||
) {
|
||||
let _config = test_config();
|
||||
let model_info = model_info_from_models_json(model_slug);
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2862,6 +2866,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_test_model_info_includes_sync_tool() {
|
||||
let _config = test_config();
|
||||
let mut model_info = model_info_from_models_json("gpt-5-codex");
|
||||
model_info.experimental_supported_tools = vec![
|
||||
"test_sync_tool".to_string(),
|
||||
@@ -3071,6 +3076,7 @@ mod tests {
|
||||
),
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -3081,6 +3087,7 @@ mod tests {
|
||||
tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})),
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
),
|
||||
])),
|
||||
|
||||
@@ -31,6 +31,22 @@ fn custom_tool_output_text_and_success(
|
||||
(output.unwrap_or_default(), success)
|
||||
}
|
||||
|
||||
fn assert_js_repl_ok(req: &ResponsesRequest, call_id: &str, expected_output: &str) {
|
||||
let (output, success) = custom_tool_output_text_and_success(req, call_id);
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"js_repl call failed unexpectedly: {output}"
|
||||
);
|
||||
assert!(output.contains(expected_output), "output was: {output}");
|
||||
}
|
||||
|
||||
fn assert_js_repl_err(req: &ResponsesRequest, call_id: &str, expected_output: &str) {
|
||||
let (output, success) = custom_tool_output_text_and_success(req, call_id);
|
||||
assert_ne!(success, Some(true), "js_repl call should fail: {output}");
|
||||
assert!(output.contains(expected_output), "output was: {output}");
|
||||
}
|
||||
|
||||
fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body["tools"]
|
||||
.as_array()
|
||||
@@ -75,6 +91,22 @@ async fn run_js_repl_turn(
|
||||
prompt: &str,
|
||||
calls: &[(&str, &str)],
|
||||
) -> Result<ResponseMock> {
|
||||
let mut mocks = run_js_repl_sequence(server, prompt, calls).await?;
|
||||
Ok(mocks
|
||||
.pop()
|
||||
.expect("js_repl test should return a request mock"))
|
||||
}
|
||||
|
||||
async fn run_js_repl_sequence(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
calls: &[(&str, &str)],
|
||||
) -> Result<Vec<ResponseMock>> {
|
||||
anyhow::ensure!(
|
||||
!calls.is_empty(),
|
||||
"js_repl test must include at least one call"
|
||||
);
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
@@ -83,24 +115,68 @@ async fn run_js_repl_turn(
|
||||
});
|
||||
let test = builder.build(server).await?;
|
||||
|
||||
let mut first_events = vec![ev_response_created("resp-1")];
|
||||
for (call_id, js_input) in calls {
|
||||
first_events.push(ev_custom_tool_call(call_id, "js_repl", js_input));
|
||||
}
|
||||
first_events.push(ev_completed("resp-1"));
|
||||
responses::mount_sse_once(server, sse(first_events)).await;
|
||||
|
||||
let second_mock = responses::mount_sse_once(
|
||||
responses::mount_sse_once(
|
||||
server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(calls[0].0, "js_repl", calls[0].1),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut mocks = Vec::with_capacity(calls.len());
|
||||
for (response_index, (call_id, js_input)) in calls.iter().enumerate().skip(1) {
|
||||
let response_id = format!("resp-{}", response_index + 1);
|
||||
let mock = responses::mount_sse_once(
|
||||
server,
|
||||
sse(vec![
|
||||
ev_response_created(&response_id),
|
||||
ev_custom_tool_call(call_id, "js_repl", js_input),
|
||||
ev_completed(&response_id),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mocks.push(mock);
|
||||
}
|
||||
|
||||
let final_response_id = format!("resp-{}", calls.len() + 1);
|
||||
let final_mock = responses::mount_sse_once(
|
||||
server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed(&final_response_id),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mocks.push(final_mock);
|
||||
|
||||
test.submit_turn(prompt).await?;
|
||||
Ok(second_mock)
|
||||
Ok(mocks)
|
||||
}
|
||||
|
||||
async fn assert_failed_cell_followup(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
failing_cell: &str,
|
||||
followup_cell: &str,
|
||||
expected_followup_output: &str,
|
||||
) -> Result<()> {
|
||||
let mocks = run_js_repl_sequence(
|
||||
server,
|
||||
prompt,
|
||||
&[("call-1", failing_cell), ("call-2", followup_cell)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(
|
||||
&mocks[1].single_request(),
|
||||
"call-2",
|
||||
expected_followup_output,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -165,68 +241,328 @@ async fn js_repl_is_not_advertised_when_startup_node_is_incompatible() -> Result
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_persists_top_level_bindings_and_supports_tla() -> Result<()> {
|
||||
async fn js_repl_persists_top_level_destructured_bindings_and_supports_tla() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::JsRepl)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
responses::mount_sse_once(
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(
|
||||
"run js_repl twice",
|
||||
&[
|
||||
(
|
||||
"call-1",
|
||||
"js_repl",
|
||||
"let x = await Promise.resolve(41); console.log(x);",
|
||||
"const { context: liveContext, session } = await Promise.resolve({ context: 41, session: 1 }); console.log(liveContext + session);",
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
("call-2", "console.log(liveContext + session);"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let second_mock = responses::mount_sse_once(
|
||||
.await?;
|
||||
|
||||
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "42");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "42");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_commit_initialized_bindings_only() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_custom_tool_call("call-2", "js_repl", "console.log(x + 1);"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
"run js_repl across a failed cell",
|
||||
&[
|
||||
("call-1", "const base = 40; console.log(base);"),
|
||||
(
|
||||
"call-2",
|
||||
"const { session } = await Promise.resolve({ session: 2 }); throw new Error(\"boom\"); const late = 99;",
|
||||
),
|
||||
("call-3", "console.log(base + session, typeof late);"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let third_mock = responses::mount_sse_once(
|
||||
.await?;
|
||||
|
||||
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "40");
|
||||
assert_js_repl_err(&mocks[1].single_request(), "call-2", "boom");
|
||||
assert_js_repl_ok(&mocks[2].single_request(), "call-3", "42 undefined");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_preserve_initialized_lexical_destructuring_bindings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
"run js_repl through partial destructuring failure",
|
||||
&[
|
||||
(
|
||||
"call-1",
|
||||
"const { a, b } = { a: 1, get b() { throw new Error(\"boom\"); } };",
|
||||
),
|
||||
(
|
||||
"call-2",
|
||||
"let aValue; try { aValue = a; } catch (error) { aValue = error.name; } let bValue; try { bValue = b; } catch (error) { bValue = error.name; } console.log(aValue, bValue);",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
|
||||
test.submit_turn("run js_repl twice").await?;
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "1 ReferenceError");
|
||||
|
||||
let req2 = second_mock.single_request();
|
||||
let (first_output, first_success) = custom_tool_output_text_and_success(&req2, "call-1");
|
||||
assert_ne!(
|
||||
first_success,
|
||||
Some(false),
|
||||
"first js_repl call failed unexpectedly: {first_output}"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_link_failures_keep_prior_module_state() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl across a link failure",
|
||||
&[
|
||||
("call-1", "const answer = 41; console.log(answer);"),
|
||||
("call-2", "import value from \"./foo\";"),
|
||||
("call-3", "console.log(answer + 1);"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "41");
|
||||
assert_js_repl_err(
|
||||
&mocks[1].single_request(),
|
||||
"call-2",
|
||||
"Top-level static import \"./foo\" is not supported in js_repl",
|
||||
);
|
||||
assert!(first_output.contains("41"));
|
||||
assert_js_repl_ok(&mocks[2].single_request(), "call-3", "42");
|
||||
|
||||
let req3 = third_mock.single_request();
|
||||
let (second_output, second_success) = custom_tool_output_text_and_success(&req3, "call-2");
|
||||
assert_ne!(
|
||||
second_success,
|
||||
Some(false),
|
||||
"second js_repl call failed unexpectedly: {second_output}"
|
||||
);
|
||||
assert!(second_output.contains("42"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_do_not_commit_unreached_hoisted_bindings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl through hoisted binding failure",
|
||||
&[
|
||||
(
|
||||
"call-1",
|
||||
"var early = 1; throw new Error(\"boom\"); var late = 2; function fn() { return 1; }",
|
||||
),
|
||||
(
|
||||
"call-2",
|
||||
"const late = 40; const fn = 1; console.log(early + late + fn);",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "42");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_do_not_preserve_hoisted_function_reads_before_declaration()
|
||||
-> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl through unsupported hoisted function reads",
|
||||
&[
|
||||
(
|
||||
"call-1",
|
||||
"foo(); throw new Error(\"boom\"); function foo() {}",
|
||||
),
|
||||
(
|
||||
"call-2",
|
||||
"let value; try { foo; value = \"present\"; } catch (error) { value = error.name; } console.log(value);",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "ReferenceError");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_preserve_functions_when_declaration_sites_are_reached() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl through supported function declaration persistence",
|
||||
&[
|
||||
("call-1", "function foo() {} throw new Error(\"boom\");"),
|
||||
("call-2", "console.log(typeof foo);"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "function");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_preserve_prior_binding_writes_without_new_bindings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl through failed prior-binding writes",
|
||||
&[
|
||||
("call-1", "let x = 1; console.log(x);"),
|
||||
("call-2", "x = 2; throw new Error(\"boom\");"),
|
||||
("call-3", "console.log(x);"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "1");
|
||||
assert_js_repl_err(&mocks[1].single_request(), "call-2", "boom");
|
||||
assert_js_repl_ok(&mocks[2].single_request(), "call-3", "2");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_var_persistence_boundaries() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let cases = [
|
||||
(
|
||||
"run js_repl through supported pre-declaration var writes",
|
||||
"x = 5; y = 1; y += 2; z = 1; z++; throw new Error(\"boom\"); var x, y, z;",
|
||||
"console.log(x, y, z);",
|
||||
"5 3 2",
|
||||
),
|
||||
(
|
||||
"run js_repl through short-circuited logical var assignments",
|
||||
"x &&= 1; y ||= 2; z ??= 3; throw new Error(\"boom\"); var x, y, z;",
|
||||
"let xValue; try { xValue = x; } catch (error) { xValue = error.name; } console.log(xValue, y, z);",
|
||||
"ReferenceError 2 3",
|
||||
),
|
||||
(
|
||||
"run js_repl through unsupported shadowed nested var writes",
|
||||
"{ let x = 1; x = 2; } throw new Error(\"boom\"); var x;",
|
||||
"let value; try { value = x; } catch (error) { value = error.name; } console.log(value);",
|
||||
"ReferenceError",
|
||||
),
|
||||
(
|
||||
"run js_repl through unsupported nested assignment writes",
|
||||
"x = (y = 1); throw new Error(\"boom\"); var x, y;",
|
||||
"let yValue; try { yValue = y; } catch (error) { yValue = error.name; } console.log(x, yValue);",
|
||||
"1 ReferenceError",
|
||||
),
|
||||
(
|
||||
"run js_repl through unsupported var destructuring recovery",
|
||||
"var { a, b } = { a: 1, get b() { throw new Error(\"boom\"); } };",
|
||||
"let aValue; try { aValue = a; } catch (error) { aValue = error.name; } let bValue; try { bValue = b; } catch (error) { bValue = error.name; } console.log(aValue, bValue);",
|
||||
"ReferenceError ReferenceError",
|
||||
),
|
||||
];
|
||||
|
||||
for (prompt, failing_cell, followup_cell, expected_followup_output) in cases {
|
||||
assert_failed_cell_followup(
|
||||
&server,
|
||||
prompt,
|
||||
failing_cell,
|
||||
followup_cell,
|
||||
expected_followup_output,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_commit_non_empty_loop_vars_but_skip_empty_loops() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl through failed loop bindings",
|
||||
&[
|
||||
(
|
||||
"call-1",
|
||||
"for (var item of [2]) {} for (var emptyItem of []) {} throw new Error(\"boom\");",
|
||||
),
|
||||
(
|
||||
"call-2",
|
||||
"let itemValue; try { itemValue = item; } catch (error) { itemValue = error.name; } let emptyValue; try { emptyValue = emptyItem; } catch (error) { emptyValue = error.name; } console.log(itemValue, emptyValue);",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "2 ReferenceError");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_keeps_function_to_string_stable() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = run_js_repl_turn(
|
||||
&server,
|
||||
"run js_repl through function toString",
|
||||
&[(
|
||||
"call-1",
|
||||
"function foo() { return 1; } console.log(foo.toString());",
|
||||
)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
assert_js_repl_ok(&req, "call-1", "function foo() { return 1; }");
|
||||
let (output, _) = custom_tool_output_text_and_success(&req, "call-1");
|
||||
assert!(!output.contains("__codexInternalMarkCommittedBindings"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_allows_globalthis_shadowing_with_instrumented_bindings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = run_js_repl_turn(
|
||||
&server,
|
||||
"run js_repl with shadowed globalThis",
|
||||
&[(
|
||||
"call-1",
|
||||
"const globalThis = {}; const value = 1; console.log(typeof globalThis, value);",
|
||||
)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
assert_js_repl_ok(&req, "call-1", "object 1");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
@@ -22,41 +21,48 @@ use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
|
||||
let plugin_root = home.path().join("plugins/cache/test/sample/local");
|
||||
let skill_dir = plugin_root.join("skills/sample-search");
|
||||
std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir");
|
||||
const SAMPLE_PLUGIN_CONFIG_NAME: &str = "sample@test";
|
||||
const SAMPLE_PLUGIN_DISPLAY_NAME: &str = "sample";
|
||||
|
||||
fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf {
|
||||
home.path().join("plugins/cache/test/sample/local")
|
||||
}
|
||||
|
||||
fn write_sample_plugin_manifest_and_config(home: &TempDir) -> std::path::PathBuf {
|
||||
let plugin_root = sample_plugin_root(home);
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
format!(r#"{{"name":"{SAMPLE_PLUGIN_DISPLAY_NAME}"}}"#),
|
||||
)
|
||||
.expect("write plugin manifest");
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!(
|
||||
"[features]\nplugins = true\n\n[plugins.\"{SAMPLE_PLUGIN_CONFIG_NAME}\"]\nenabled = true\n"
|
||||
),
|
||||
)
|
||||
.expect("write config");
|
||||
plugin_root
|
||||
}
|
||||
|
||||
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
let skill_dir = plugin_root.join("skills/sample-search");
|
||||
std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir");
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\ndescription: inspect sample data\n---\n\n# body\n",
|
||||
)
|
||||
.expect("write plugin skill");
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
|
||||
)
|
||||
.expect("write config");
|
||||
skill_dir.join("SKILL.md")
|
||||
}
|
||||
|
||||
fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
|
||||
let plugin_root = home.path().join("plugins/cache/test/sample/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.expect("write plugin manifest");
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
format!(
|
||||
@@ -70,21 +76,10 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
|
||||
),
|
||||
)
|
||||
.expect("write plugin mcp config");
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
|
||||
)
|
||||
.expect("write config");
|
||||
}
|
||||
|
||||
fn write_plugin_app_plugin(home: &TempDir) {
|
||||
let plugin_root = home.path().join("plugins/sample");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.expect("write plugin manifest");
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
std::fs::write(
|
||||
plugin_root.join(".app.json"),
|
||||
r#"{
|
||||
@@ -96,14 +91,6 @@ fn write_plugin_app_plugin(home: &TempDir) {
|
||||
}"#,
|
||||
)
|
||||
.expect("write plugin app config");
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!(
|
||||
"[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n",
|
||||
plugin_root.display()
|
||||
),
|
||||
)
|
||||
.expect("write config");
|
||||
}
|
||||
|
||||
async fn build_plugin_test_codex(
|
||||
@@ -120,6 +107,32 @@ async fn build_plugin_test_codex(
|
||||
.codex)
|
||||
}
|
||||
|
||||
async fn build_apps_enabled_plugin_test_codex(
|
||||
server: &MockServer,
|
||||
codex_home: Arc<TempDir>,
|
||||
chatgpt_base_url: String,
|
||||
) -> Result<Arc<codex_core::CodexThread>> {
|
||||
let mut builder = test_codex()
|
||||
.with_home(codex_home)
|
||||
.with_auth(CodexAuth::from_api_key("Test API Key"))
|
||||
.with_config(move |config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.disable(Feature::AppsMcpGateway)
|
||||
.expect("test config should allow feature update");
|
||||
config.chatgpt_base_url = chatgpt_base_url;
|
||||
});
|
||||
Ok(builder
|
||||
.build(server)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.codex)
|
||||
}
|
||||
|
||||
fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body.get("tools")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
@@ -137,6 +150,22 @@ fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn tool_description(body: &serde_json::Value, tool_name: &str) -> Option<String> {
|
||||
body.get("tools")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|tools| {
|
||||
tools.iter().find_map(|tool| {
|
||||
if tool.get("name").and_then(serde_json::Value::as_str) == Some(tool_name) {
|
||||
tool.get("description")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -149,7 +178,7 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let codex_home = Arc::new(TempDir::new()?);
|
||||
let skill_path = write_plugin_skill_plugin(codex_home.as_ref());
|
||||
write_plugin_skill_plugin(codex_home.as_ref());
|
||||
let codex = build_plugin_test_codex(&server, Arc::clone(&codex_home)).await?;
|
||||
|
||||
codex
|
||||
@@ -174,76 +203,48 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||
"expected plugins section present"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.contains("### Available plugins\n- `sample`"),
|
||||
"expected enabled plugin list in instructions"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.contains("### How to use plugins"),
|
||||
"expected plugin usage guidance heading"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.contains("## Skills"),
|
||||
"expected skills section present"
|
||||
instructions_text.contains("`sample`"),
|
||||
"expected enabled plugin name in instructions"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.contains("sample:sample-search: inspect sample data"),
|
||||
"expected namespaced plugin skill summary"
|
||||
);
|
||||
let expected_path = normalize_path(skill_path)?;
|
||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
instructions_text.contains(&expected_path_str),
|
||||
"expected path {expected_path_str} in instructions"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.find("## Plugins") < instructions_text.find("## Skills"),
|
||||
"expected plugins section before skills section"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
|
||||
async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
|
||||
let mock = mount_sse_sequence(
|
||||
let mock = mount_sse_once(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
],
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = Arc::new(TempDir::new()?);
|
||||
let rmcp_test_server_bin = match stdio_server_bin() {
|
||||
Ok(bin) => bin,
|
||||
Err(err) => {
|
||||
eprintln!("test_stdio_server binary not available, skipping test: {err}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
write_plugin_skill_plugin(codex_home.as_ref());
|
||||
write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin);
|
||||
write_plugin_app_plugin(codex_home.as_ref());
|
||||
#[allow(clippy::expect_used)]
|
||||
let mut builder = test_codex()
|
||||
.with_home(codex_home)
|
||||
.with_auth(CodexAuth::from_api_key("Test API Key"))
|
||||
.with_config(move |config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.disable(Feature::AppsMcpGateway)
|
||||
.expect("test config should allow feature update");
|
||||
config.chatgpt_base_url = apps_server.chatgpt_base_url;
|
||||
});
|
||||
let codex = builder
|
||||
.build(&server)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.codex;
|
||||
|
||||
let codex =
|
||||
build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url)
|
||||
.await?;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![codex_protocol::user_input::UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text: "Use @sample for this task.".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
@@ -251,40 +252,46 @@ async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![codex_protocol::user_input::UserInput::Text {
|
||||
text: "Use $google-calendar and then call tools.".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 2, "expected two model requests");
|
||||
|
||||
let first_tools = tool_names(&requests[0].body_json());
|
||||
let request = mock.single_request();
|
||||
let developer_messages = request.message_input_texts("developer");
|
||||
assert!(
|
||||
!first_tools
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
||||
"app tools should stay hidden before plugin app mention: {first_tools:?}"
|
||||
);
|
||||
|
||||
let second_tools = tool_names(&requests[1].body_json());
|
||||
assert!(
|
||||
second_tools
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
||||
"calendar create tool should be available after plugin app mention: {second_tools:?}"
|
||||
.any(|text| text.contains("Skills from this plugin")),
|
||||
"expected plugin skills guidance: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
second_tools
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_list_events"),
|
||||
"calendar list tool should be available after plugin app mention: {second_tools:?}"
|
||||
.any(|text| text.contains("MCP servers from this plugin")),
|
||||
"expected visible plugin MCP guidance: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|text| text.contains("Apps from this plugin")),
|
||||
"expected visible plugin app guidance: {developer_messages:?}"
|
||||
);
|
||||
let request_body = request.body_json();
|
||||
let request_tools = tool_names(&request_body);
|
||||
assert!(
|
||||
request_tools
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
||||
"expected plugin app tools to become visible for this turn: {request_tools:?}"
|
||||
);
|
||||
let echo_description = tool_description(&request_body, "mcp__sample__echo")
|
||||
.expect("plugin MCP tool description should be present");
|
||||
assert!(
|
||||
echo_description.contains("This tool is part of plugin `sample`."),
|
||||
"expected plugin MCP provenance in tool description: {echo_description:?}"
|
||||
);
|
||||
let calendar_description =
|
||||
tool_description(&request_body, "mcp__codex_apps__calendar_create_event")
|
||||
.expect("plugin app tool description should be present");
|
||||
assert!(
|
||||
calendar_description.contains("This tool is part of plugin `sample`."),
|
||||
"expected plugin app provenance in tool description: {calendar_description:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -27,6 +27,7 @@ fn resume_history(
|
||||
let turn_id = "resume-warning-seed-turn".to_string();
|
||||
let turn_ctx = TurnContextItem {
|
||||
turn_id: Some(turn_id.clone()),
|
||||
trace_id: None,
|
||||
cwd: config.cwd.clone(),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
|
||||
@@ -361,6 +361,110 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn search_tool_results_match_plugin_names_and_annotate_descriptions() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
|
||||
let call_id = "tool-search";
|
||||
let args = json!({
|
||||
"query": "sample",
|
||||
"limit": 2,
|
||||
});
|
||||
let mock = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
call_id,
|
||||
SEARCH_TOOL_BM25_TOOL_NAME,
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = Arc::new(tempfile::TempDir::new()?);
|
||||
let plugin_root = codex_home.path().join("plugins/cache/test/sample/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.expect("write plugin manifest");
|
||||
std::fs::write(
|
||||
plugin_root.join(".app.json"),
|
||||
r#"{
|
||||
"apps": {
|
||||
"calendar": {
|
||||
"id": "calendar"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write plugin app config");
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let mut builder =
|
||||
configured_builder(apps_server.chatgpt_base_url.clone(), None).with_home(codex_home);
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn_with_policies(
|
||||
"find sample plugin tools",
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
2,
|
||||
"expected 2 requests, got {}",
|
||||
requests.len()
|
||||
);
|
||||
|
||||
let search_output_payload = search_tool_output_payload(&requests[1], call_id);
|
||||
let result_tools = search_result_tools(&search_output_payload);
|
||||
assert_eq!(result_tools.len(), 2, "expected 2 search results");
|
||||
assert!(
|
||||
result_tools.iter().all(|tool| {
|
||||
tool.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|description| {
|
||||
description.contains("This tool is part of plugin `sample`.")
|
||||
})
|
||||
}),
|
||||
"expected plugin provenance in search result descriptions: {search_output_payload:?}"
|
||||
);
|
||||
assert!(
|
||||
result_tools
|
||||
.iter()
|
||||
.any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_CREATE_TOOL) }),
|
||||
"expected calendar create tool in search results: {search_output_payload:?}"
|
||||
);
|
||||
assert!(
|
||||
result_tools
|
||||
.iter()
|
||||
.any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_LIST_TOOL) }),
|
||||
"expected calendar list tool in search results: {search_output_payload:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn search_tool_selection_persists_across_turns() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -691,6 +691,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
|
||||
server_name: ev.server_name.clone(),
|
||||
request_id: ev.id.clone(),
|
||||
decision: ElicitationAction::Cancel,
|
||||
content: None,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ codex-protocol = { workspace = true }
|
||||
sentry = { version = "0.46" }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
const OPENAI_BASE_URL_ENV_VAR: &str = "OPENAI_BASE_URL";
|
||||
pub const FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME: &str = "codex-connectivity-diagnostics.txt";
|
||||
const PROXY_ENV_VARS: &[&str] = &[
|
||||
@@ -49,16 +46,8 @@ impl FeedbackDiagnostics {
|
||||
let proxy_details = PROXY_ENV_VARS
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
let value = env.get(*key)?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let detail = match sanitize_proxy_value(value) {
|
||||
Some(sanitized) => format!("{key} = {sanitized}"),
|
||||
None => format!("{key} = invalid value"),
|
||||
};
|
||||
Some(detail)
|
||||
let value = env.get(*key)?;
|
||||
Some(format!("{key} = {value}"))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !proxy_details.is_empty() {
|
||||
@@ -70,17 +59,10 @@ impl FeedbackDiagnostics {
|
||||
}
|
||||
|
||||
if let Some(value) = env.get(OPENAI_BASE_URL_ENV_VAR).map(String::as_str) {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() && trimmed.trim_end_matches('/') != DEFAULT_OPENAI_BASE_URL {
|
||||
let detail = match sanitize_url_for_display(trimmed) {
|
||||
Some(sanitized) => format!("{OPENAI_BASE_URL_ENV_VAR} = {sanitized}"),
|
||||
None => format!("{OPENAI_BASE_URL_ENV_VAR} = invalid value"),
|
||||
};
|
||||
diagnostics.push(FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec![detail],
|
||||
});
|
||||
}
|
||||
diagnostics.push(FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec![format!("{OPENAI_BASE_URL_ENV_VAR} = {value}")],
|
||||
});
|
||||
}
|
||||
|
||||
Self { diagnostics }
|
||||
@@ -114,40 +96,15 @@ impl FeedbackDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sanitize_url_for_display(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Ok(mut url) = Url::parse(trimmed) else {
|
||||
return None;
|
||||
};
|
||||
let _ = url.set_username("");
|
||||
let _ = url.set_password(None);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Some(url.to_string().trim_end_matches('/').to_string()).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn sanitize_proxy_value(raw: &str) -> Option<String> {
|
||||
if raw.contains("://") {
|
||||
return sanitize_url_for_display(raw);
|
||||
}
|
||||
|
||||
sanitize_url_for_display(&format!("http://{raw}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::FeedbackDiagnostic;
|
||||
use super::FeedbackDiagnostics;
|
||||
use super::sanitize_url_for_display;
|
||||
|
||||
#[test]
|
||||
fn collect_from_pairs_reports_sanitized_diagnostics_and_attachment() {
|
||||
fn collect_from_pairs_reports_raw_values_and_attachment() {
|
||||
let diagnostics = FeedbackDiagnostics::collect_from_pairs([
|
||||
(
|
||||
"HTTPS_PROXY",
|
||||
@@ -167,14 +124,16 @@ mod tests {
|
||||
"Proxy environment variables are set and may affect connectivity."
|
||||
.to_string(),
|
||||
details: vec![
|
||||
"http_proxy = http://proxy.example.com:8080".to_string(),
|
||||
"HTTPS_PROXY = https://secure-proxy.example.com".to_string(),
|
||||
"http_proxy = proxy.example.com:8080".to_string(),
|
||||
"HTTPS_PROXY = https://user:password@secure-proxy.example.com:443?secret=1".to_string(),
|
||||
"all_proxy = socks5h://all-proxy.example.com:1080".to_string(),
|
||||
],
|
||||
},
|
||||
FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec!["OPENAI_BASE_URL = https://example.com/v1".to_string()],
|
||||
details: vec![
|
||||
"OPENAI_BASE_URL = https://example.com/v1?token=secret".to_string(),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -183,33 +142,42 @@ mod tests {
|
||||
assert_eq!(
|
||||
diagnostics.attachment_text(),
|
||||
Some(
|
||||
"Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - http_proxy = http://proxy.example.com:8080\n - HTTPS_PROXY = https://secure-proxy.example.com\n - all_proxy = socks5h://all-proxy.example.com:1080\n- OPENAI_BASE_URL is set and may affect connectivity.\n - OPENAI_BASE_URL = https://example.com/v1"
|
||||
"Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - http_proxy = proxy.example.com:8080\n - HTTPS_PROXY = https://user:password@secure-proxy.example.com:443?secret=1\n - all_proxy = socks5h://all-proxy.example.com:1080\n- OPENAI_BASE_URL is set and may affect connectivity.\n - OPENAI_BASE_URL = https://example.com/v1?token=secret"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_from_pairs_ignores_absent_and_default_values() {
|
||||
for diagnostics in [
|
||||
FeedbackDiagnostics::collect_from_pairs(Vec::<(String, String)>::new()),
|
||||
FeedbackDiagnostics::collect_from_pairs([(
|
||||
"OPENAI_BASE_URL",
|
||||
"https://api.openai.com/v1/",
|
||||
)]),
|
||||
] {
|
||||
assert_eq!(diagnostics, FeedbackDiagnostics::default());
|
||||
assert_eq!(diagnostics.attachment_text(), None);
|
||||
}
|
||||
fn collect_from_pairs_ignores_absent_values() {
|
||||
let diagnostics = FeedbackDiagnostics::collect_from_pairs(Vec::<(String, String)>::new());
|
||||
assert_eq!(diagnostics, FeedbackDiagnostics::default());
|
||||
assert_eq!(diagnostics.attachment_text(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_from_pairs_reports_invalid_values_without_echoing_them() {
|
||||
let invalid_proxy = "not a valid\nproxy";
|
||||
let invalid_base_url = "not a valid\nurl";
|
||||
fn collect_from_pairs_preserves_openai_base_url_literal_value() {
|
||||
let diagnostics = FeedbackDiagnostics::collect_from_pairs([(
|
||||
"OPENAI_BASE_URL",
|
||||
"https://api.openai.com/v1/",
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
diagnostics,
|
||||
FeedbackDiagnostics {
|
||||
diagnostics: vec![FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec!["OPENAI_BASE_URL = https://api.openai.com/v1/".to_string()],
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_from_pairs_preserves_whitespace_and_empty_values() {
|
||||
let diagnostics = FeedbackDiagnostics::collect_from_pairs([
|
||||
("HTTP_PROXY", invalid_proxy),
|
||||
("OPENAI_BASE_URL", invalid_base_url),
|
||||
("HTTP_PROXY", " proxy with spaces "),
|
||||
("OPENAI_BASE_URL", ""),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
@@ -220,28 +188,42 @@ mod tests {
|
||||
headline:
|
||||
"Proxy environment variables are set and may affect connectivity."
|
||||
.to_string(),
|
||||
details: vec!["HTTP_PROXY = invalid value".to_string()],
|
||||
details: vec!["HTTP_PROXY = proxy with spaces ".to_string()],
|
||||
},
|
||||
FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec!["OPENAI_BASE_URL = invalid value".to_string()],
|
||||
details: vec!["OPENAI_BASE_URL = ".to_string()],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
let attachment_text = diagnostics
|
||||
.attachment_text()
|
||||
.expect("invalid diagnostics should still render attachment text");
|
||||
assert!(!attachment_text.contains(invalid_proxy));
|
||||
assert!(!attachment_text.contains(invalid_base_url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_url_for_display_strips_credentials_query_and_fragment() {
|
||||
let sanitized = sanitize_url_for_display(
|
||||
"https://user:password@example.com:8443/v1?token=secret#fragment",
|
||||
);
|
||||
fn collect_from_pairs_reports_values_verbatim() {
|
||||
let proxy_value = "not a valid proxy";
|
||||
let base_url_value = "hello";
|
||||
let diagnostics = FeedbackDiagnostics::collect_from_pairs([
|
||||
("HTTP_PROXY", proxy_value),
|
||||
("OPENAI_BASE_URL", base_url_value),
|
||||
]);
|
||||
|
||||
assert_eq!(sanitized, Some("https://example.com:8443/v1".to_string()));
|
||||
assert_eq!(
|
||||
diagnostics,
|
||||
FeedbackDiagnostics {
|
||||
diagnostics: vec![
|
||||
FeedbackDiagnostic {
|
||||
headline:
|
||||
"Proxy environment variables are set and may affect connectivity."
|
||||
.to_string(),
|
||||
details: vec!["HTTP_PROXY = not a valid proxy".to_string()],
|
||||
},
|
||||
FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec!["OPENAI_BASE_URL = hello".to_string()],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ into this binary.
|
||||
- When enabled, symlink-in-path and non-existent protected paths inside
|
||||
writable roots are blocked by mounting `/dev/null` on the symlink or first
|
||||
missing component.
|
||||
- When enabled, the helper isolates the PID namespace via `--unshare-pid`.
|
||||
- When enabled, the helper explicitly isolates the user namespace via
|
||||
`--unshare-user` and the PID namespace via `--unshare-pid`.
|
||||
- When enabled and network is restricted without proxy routing, the helper also
|
||||
isolates the network namespace via `--unshare-net`.
|
||||
- In managed proxy mode, the helper uses `--unshare-net` plus an internal
|
||||
|
||||
@@ -107,6 +107,9 @@ fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOption
|
||||
"--bind".to_string(),
|
||||
"/".to_string(),
|
||||
"/".to_string(),
|
||||
// Always enter a fresh user namespace so root inside a container does
|
||||
// not need ambient CAP_SYS_ADMIN to create the remaining namespaces.
|
||||
"--unshare-user".to_string(),
|
||||
"--unshare-pid".to_string(),
|
||||
];
|
||||
if options.network_mode.should_unshare_network() {
|
||||
@@ -132,6 +135,9 @@ fn create_bwrap_flags(
|
||||
args.push("--new-session".to_string());
|
||||
args.push("--die-with-parent".to_string());
|
||||
args.extend(create_filesystem_args(sandbox_policy, cwd)?);
|
||||
// Request a user namespace explicitly rather than relying on bubblewrap's
|
||||
// auto-enable behavior, which is skipped when the caller runs as uid 0.
|
||||
args.push("--unshare-user".to_string());
|
||||
// Isolate the PID namespace.
|
||||
args.push("--unshare-pid".to_string());
|
||||
if options.network_mode.should_unshare_network() {
|
||||
@@ -425,6 +431,7 @@ mod tests {
|
||||
"--bind".to_string(),
|
||||
"/".to_string(),
|
||||
"/".to_string(),
|
||||
"--unshare-user".to_string(),
|
||||
"--unshare-pid".to_string(),
|
||||
"--unshare-net".to_string(),
|
||||
"--proc".to_string(),
|
||||
|
||||
@@ -49,6 +49,7 @@ fn inserts_bwrap_argv0_before_command_separator() {
|
||||
"/".to_string(),
|
||||
"--dev".to_string(),
|
||||
"/dev".to_string(),
|
||||
"--unshare-user".to_string(),
|
||||
"--unshare-pid".to_string(),
|
||||
"--proc".to_string(),
|
||||
"/proc".to_string(),
|
||||
|
||||
@@ -26,6 +26,7 @@ pub use crate::metrics::runtime_metrics::RuntimeMetricTotals;
|
||||
pub use crate::metrics::runtime_metrics::RuntimeMetricsSummary;
|
||||
pub use crate::otel_provider::traceparent_context_from_env;
|
||||
pub use crate::trace_context::context_from_w3c_trace_context;
|
||||
pub use crate::trace_context::current_span_trace_id;
|
||||
pub use crate::trace_context::current_span_w3c_trace_context;
|
||||
pub use crate::trace_context::set_parent_from_context;
|
||||
pub use crate::trace_context::set_parent_from_w3c_trace_context;
|
||||
|
||||
@@ -23,6 +23,17 @@ pub fn current_span_w3c_trace_context() -> Option<W3cTraceContext> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn current_span_trace_id() -> Option<String> {
|
||||
let context = Span::current().context();
|
||||
let span = context.span();
|
||||
let span_context = span.span_context();
|
||||
if !span_context.is_valid() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(span_context.trace_id().to_string())
|
||||
}
|
||||
|
||||
pub fn context_from_w3c_trace_context(trace: &W3cTraceContext) -> Option<Context> {
|
||||
context_from_trace_headers(trace.traceparent.as_deref(), trace.tracestate.as_deref())
|
||||
}
|
||||
@@ -62,11 +73,17 @@ pub(crate) fn context_from_trace_headers(
|
||||
mod tests {
|
||||
use super::context_from_trace_headers;
|
||||
use super::context_from_w3c_trace_context;
|
||||
use super::current_span_trace_id;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use opentelemetry::trace::SpanId;
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
use opentelemetry::trace::TraceId;
|
||||
use opentelemetry::trace::TracerProvider as _;
|
||||
use opentelemetry_sdk::trace::SdkTracerProvider;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tracing::trace_span;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
#[test]
|
||||
fn parses_valid_w3c_trace_context() {
|
||||
@@ -103,4 +120,21 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_span_trace_id_returns_hex_trace_id() {
|
||||
let provider = SdkTracerProvider::builder().build();
|
||||
let tracer = provider.tracer("codex-otel-tests");
|
||||
let subscriber =
|
||||
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
|
||||
let _guard = subscriber.set_default();
|
||||
|
||||
let span = trace_span!("test_span");
|
||||
let _entered = span.enter();
|
||||
let trace_id = current_span_trace_id().expect("trace id");
|
||||
|
||||
assert_eq!(trace_id.len(), 32);
|
||||
assert!(trace_id.chars().all(|ch| ch.is_ascii_hexdigit()));
|
||||
assert_ne!(trace_id, "00000000000000000000000000000000");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fd-lock = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "stream"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -12,7 +13,7 @@ sha2 = { workspace = true }
|
||||
tar = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "rt", "sync"] }
|
||||
tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] }
|
||||
url = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# codex-package-manager
|
||||
|
||||
`codex-package-manager` is the shared installer used for versioned runtime bundles and other cached artifacts in `codex-rs`.
|
||||
|
||||
It owns the generic parts of package installation:
|
||||
|
||||
- current-platform detection
|
||||
- manifest and archive fetches
|
||||
- checksum and archive-size validation
|
||||
- archive extraction for `.zip` and `.tar.gz`
|
||||
- staging and promotion into a versioned cache directory
|
||||
- cross-process install locking
|
||||
|
||||
Package-specific code stays behind the `ManagedPackage` trait.
|
||||
|
||||
## Model
|
||||
|
||||
The package manager is intentionally small:
|
||||
|
||||
1. A `ManagedPackage` implementation describes how to fetch a manifest, choose an archive for a `PackagePlatform`, and load a validated installed package from disk.
|
||||
2. `PackageManager::resolve_cached()` returns a cached install for the current platform if `load_installed()` succeeds and the version matches.
|
||||
3. `PackageManager::ensure_installed()` acquires a per-install lock, downloads the archive into a staging directory, extracts it, validates the staged package, and promotes it into the cache.
|
||||
|
||||
The default cache root is:
|
||||
|
||||
```text
|
||||
<codex_home>/<default_cache_root_relative>
|
||||
```
|
||||
|
||||
Callers can override that root with `PackageManagerConfig::with_cache_root(...)`.
|
||||
|
||||
## ManagedPackage Contract
|
||||
|
||||
The trait is small, but the invariants matter:
|
||||
|
||||
- `install_dir()` should be unique per package version and platform. If two versions or two platforms share a directory, promotion and cleanup become unsafe.
|
||||
- `load_installed()` must fully validate the installed package, not just deserialize a manifest. `resolve_cached()` trusts a successful load as a valid cache hit.
|
||||
- The default `detect_extracted_root()` looks for `manifest.json` at the extraction root or inside a single top-level directory. Override it if your package layout differs.
|
||||
- `archive_url()` should be derived from manifest data, not recomputed from unrelated caller state, so manifest selection and download stay aligned.
|
||||
|
||||
## Consumer Guidance
|
||||
|
||||
- If your feature can install on demand, do not gate feature registration on a preinstalled-cache check alone. `resolve_cached()` only answers "is it already present?" while `ensure_installed()` is the bootstrap path.
|
||||
- Keep cache-root overrides inside your manager/config surface. Separate helpers that reconstruct install paths can drift from `PackageManagerConfig`.
|
||||
- Prefer surfacing package-specific validation failures from `load_installed()` when debugging. The generic manager treats failed cache loads as cache misses today.
|
||||
|
||||
## Security and Extraction Rules
|
||||
|
||||
- `.zip` extraction rejects entries that escape the extraction root and preserves Unix executable bits when the archive carries them.
|
||||
- `.tar.gz` extraction rejects symlinks, hard links, sparse files, device files, and FIFOs. Only regular files and directories are promoted.
|
||||
- The archive SHA-256 is always verified, and `size_bytes` is enforced when present in the manifest.
|
||||
|
||||
## Extending It
|
||||
|
||||
Typical usage looks like this:
|
||||
|
||||
```rust,ignore
|
||||
let config = PackageManagerConfig::new(codex_home, MyPackage::new(...));
|
||||
let manager = PackageManager::new(config);
|
||||
|
||||
let package = manager.ensure_installed().await?;
|
||||
```
|
||||
|
||||
In practice, most packages should expose their own small wrapper config/manager types over the generic crate so the rest of the codebase does not depend on `ManagedPackage` details directly.
|
||||
@@ -0,0 +1,270 @@
|
||||
use crate::PackageManagerError;
|
||||
use flate2::read::GzDecoder;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::fs::File;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tar::Archive;
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// Archive metadata for a platform entry in a release manifest.
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||
pub struct PackageReleaseArchive {
|
||||
/// Archive file name relative to the package release location.
|
||||
pub archive: String,
|
||||
/// Expected SHA-256 of the downloaded archive body.
|
||||
pub sha256: String,
|
||||
/// Archive format used by the download.
|
||||
pub format: ArchiveFormat,
|
||||
/// Expected archive length in bytes, when the manifest provides it.
|
||||
pub size_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
/// Archive formats supported by the generic extractor.
|
||||
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||
pub enum ArchiveFormat {
|
||||
/// A `.zip` archive.
|
||||
#[serde(rename = "zip")]
|
||||
Zip,
|
||||
/// A `.tar.gz` archive.
|
||||
#[serde(rename = "tar.gz")]
|
||||
TarGz,
|
||||
}
|
||||
|
||||
/// Detects a package root with a `manifest.json` in an extraction directory.
|
||||
pub(crate) fn detect_single_package_root(
|
||||
extraction_root: &Path,
|
||||
) -> Result<PathBuf, PackageManagerError> {
|
||||
let direct_manifest = extraction_root.join("manifest.json");
|
||||
if direct_manifest.exists() {
|
||||
return Ok(extraction_root.to_path_buf());
|
||||
}
|
||||
|
||||
let mut directory_candidates = Vec::new();
|
||||
for entry in std::fs::read_dir(extraction_root).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read {}", extraction_root.display()),
|
||||
source,
|
||||
})? {
|
||||
let entry = entry.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read entry in {}", extraction_root.display()),
|
||||
source,
|
||||
})?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
directory_candidates.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if directory_candidates.len() == 1 {
|
||||
let candidate = &directory_candidates[0];
|
||||
if candidate.join("manifest.json").exists() {
|
||||
return Ok(candidate.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Err(PackageManagerError::MissingPackageRoot(
|
||||
extraction_root.to_path_buf(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn verify_archive_size(
|
||||
bytes: &[u8],
|
||||
expected: Option<u64>,
|
||||
) -> Result<(), PackageManagerError> {
|
||||
let Some(expected) = expected else {
|
||||
return Ok(());
|
||||
};
|
||||
let actual = bytes.len() as u64;
|
||||
if actual == expected {
|
||||
return Ok(());
|
||||
}
|
||||
Err(PackageManagerError::UnexpectedArchiveSize { expected, actual })
|
||||
}
|
||||
|
||||
pub(crate) fn verify_sha256(bytes: &[u8], expected: &str) -> Result<(), PackageManagerError> {
|
||||
let actual = format!("{:x}", Sha256::digest(bytes));
|
||||
if actual == expected.to_ascii_lowercase() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(PackageManagerError::ChecksumMismatch {
|
||||
expected: expected.to_string(),
|
||||
actual,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn extract_archive(
|
||||
archive_path: &Path,
|
||||
destination: &Path,
|
||||
format: ArchiveFormat,
|
||||
) -> Result<(), PackageManagerError> {
|
||||
match format {
|
||||
ArchiveFormat::Zip => extract_zip_archive(archive_path, destination),
|
||||
ArchiveFormat::TarGz => extract_tar_gz_archive(archive_path, destination),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_zip_archive(archive_path: &Path, destination: &Path) -> Result<(), PackageManagerError> {
|
||||
let file = File::open(archive_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to open {}", archive_path.display()),
|
||||
source,
|
||||
})?;
|
||||
let mut archive = ZipArchive::new(file)
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
let Some(relative_path) = entry.enclosed_name() else {
|
||||
return Err(PackageManagerError::ArchiveExtraction(format!(
|
||||
"zip entry `{}` escapes extraction root",
|
||||
entry.name()
|
||||
)));
|
||||
};
|
||||
let output_path = destination.join(relative_path);
|
||||
if entry.is_dir() {
|
||||
std::fs::create_dir_all(&output_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", output_path.display()),
|
||||
source,
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", parent.display()),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let mut output = File::create(&output_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", output_path.display()),
|
||||
source,
|
||||
})?;
|
||||
std::io::copy(&mut entry, &mut output).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to write {}", output_path.display()),
|
||||
source,
|
||||
})?;
|
||||
apply_zip_permissions(&entry, &output_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn apply_zip_permissions(
|
||||
entry: &zip::read::ZipFile<'_>,
|
||||
output_path: &Path,
|
||||
) -> Result<(), PackageManagerError> {
|
||||
let Some(mode) = entry.unix_mode() else {
|
||||
return Ok(());
|
||||
};
|
||||
std::fs::set_permissions(output_path, std::fs::Permissions::from_mode(mode)).map_err(|source| {
|
||||
PackageManagerError::Io {
|
||||
context: format!("failed to set permissions on {}", output_path.display()),
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn apply_zip_permissions(
|
||||
_entry: &zip::read::ZipFile<'_>,
|
||||
_output_path: &Path,
|
||||
) -> Result<(), PackageManagerError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar_gz_archive(
|
||||
archive_path: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<(), PackageManagerError> {
|
||||
let file = File::open(archive_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to open {}", archive_path.display()),
|
||||
source,
|
||||
})?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = Archive::new(decoder);
|
||||
for entry in archive
|
||||
.entries()
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?
|
||||
{
|
||||
let mut entry =
|
||||
entry.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
let path = entry
|
||||
.path()
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
let output_path = safe_extract_path(destination, path.as_ref())?;
|
||||
let entry_type = entry.header().entry_type();
|
||||
|
||||
if entry_type.is_symlink()
|
||||
|| entry_type.is_hard_link()
|
||||
|| entry_type.is_block_special()
|
||||
|| entry_type.is_character_special()
|
||||
|| entry_type.is_fifo()
|
||||
|| entry_type.is_gnu_sparse()
|
||||
{
|
||||
return Err(PackageManagerError::ArchiveExtraction(format!(
|
||||
"tar entry `{}` has unsupported type",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if entry_type.is_pax_global_extensions()
|
||||
|| entry_type.is_pax_local_extensions()
|
||||
|| entry_type.is_gnu_longname()
|
||||
|| entry_type.is_gnu_longlink()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if entry_type.is_dir() {
|
||||
std::fs::create_dir_all(&output_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", output_path.display()),
|
||||
source,
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !entry_type.is_file() && !entry_type.is_contiguous() {
|
||||
return Err(PackageManagerError::ArchiveExtraction(format!(
|
||||
"tar entry `{}` has unsupported type",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", parent.display()),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
entry
|
||||
.unpack(&output_path)
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn safe_extract_path(root: &Path, relative_path: &Path) -> Result<PathBuf, PackageManagerError> {
|
||||
let mut clean_relative = PathBuf::new();
|
||||
for component in relative_path.components() {
|
||||
match component {
|
||||
Component::Normal(segment) => clean_relative.push(segment),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||||
return Err(PackageManagerError::ArchiveExtraction(format!(
|
||||
"entry `{}` escapes extraction root",
|
||||
relative_path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if clean_relative.as_os_str().is_empty() {
|
||||
return Err(PackageManagerError::ArchiveExtraction(
|
||||
"archive entry had an empty path".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(root.join(clean_relative))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use crate::ManagedPackage;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Immutable configuration for a [`crate::PackageManager`] instance.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PackageManagerConfig<P> {
|
||||
pub(crate) codex_home: PathBuf,
|
||||
pub(crate) package: P,
|
||||
cache_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl<P> PackageManagerConfig<P> {
|
||||
/// Creates a config rooted at the provided Codex home directory.
|
||||
pub fn new(codex_home: PathBuf, package: P) -> Self {
|
||||
Self {
|
||||
codex_home,
|
||||
package,
|
||||
cache_root: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the package cache root instead of deriving it from `codex_home`.
|
||||
pub fn with_cache_root(mut self, cache_root: PathBuf) -> Self {
|
||||
self.cache_root = Some(cache_root);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: ManagedPackage> PackageManagerConfig<P> {
|
||||
/// Returns the effective cache root for the package.
|
||||
pub fn cache_root(&self) -> PathBuf {
|
||||
self.cache_root.clone().unwrap_or_else(|| {
|
||||
self.codex_home.join(
|
||||
self.package
|
||||
.default_cache_root_relative()
|
||||
.replace('/', std::path::MAIN_SEPARATOR_STR),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors returned by the generic package manager.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PackageManagerError {
|
||||
/// The current machine OS/architecture pair is not supported by the package.
|
||||
#[error("unsupported platform: {os}-{arch}")]
|
||||
UnsupportedPlatform { os: String, arch: String },
|
||||
|
||||
/// The configured release base URL could not be joined with a package-specific path.
|
||||
#[error("invalid release base url")]
|
||||
InvalidBaseUrl(#[source] url::ParseError),
|
||||
|
||||
/// An HTTP request failed while fetching the manifest or archive.
|
||||
#[error("{context}")]
|
||||
Http {
|
||||
context: String,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
|
||||
/// A filesystem operation failed while reading, staging, or promoting a package.
|
||||
#[error("{context}")]
|
||||
Io {
|
||||
context: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// The release manifest did not contain an archive for the current platform.
|
||||
#[error("missing platform entry `{0}` in release manifest")]
|
||||
MissingPlatform(String),
|
||||
|
||||
/// The release manifest or installed package reported a different version than requested.
|
||||
#[error("unexpected package version: expected `{expected}`, got `{actual}`")]
|
||||
UnexpectedPackageVersion { expected: String, actual: String },
|
||||
|
||||
/// The downloaded archive length did not match the manifest metadata.
|
||||
#[error("unexpected archive size: expected `{expected}`, got `{actual}`")]
|
||||
UnexpectedArchiveSize { expected: u64, actual: u64 },
|
||||
|
||||
/// The downloaded archive checksum did not match the manifest metadata.
|
||||
#[error("checksum mismatch: expected `{expected}`, got `{actual}`")]
|
||||
ChecksumMismatch { expected: String, actual: String },
|
||||
|
||||
/// Archive extraction failed or the archive contents violated extraction rules.
|
||||
#[error("archive extraction failed: {0}")]
|
||||
ArchiveExtraction(String),
|
||||
|
||||
/// The extracted archive layout did not contain a detectable package root.
|
||||
#[error("archive did not contain a package root with manifest.json under {0}")]
|
||||
MissingPackageRoot(PathBuf),
|
||||
}
|
||||
@@ -1,793 +1,17 @@
|
||||
use flate2::read::GzDecoder;
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::fs::File;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tar::Archive;
|
||||
use tempfile::tempdir_in;
|
||||
use thiserror::Error;
|
||||
use tokio::fs;
|
||||
use url::Url;
|
||||
use zip::ZipArchive;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PackageManagerConfig<P> {
|
||||
codex_home: PathBuf,
|
||||
package: P,
|
||||
cache_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl<P> PackageManagerConfig<P> {
|
||||
pub fn new(codex_home: PathBuf, package: P) -> Self {
|
||||
Self {
|
||||
codex_home,
|
||||
package,
|
||||
cache_root: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_cache_root(mut self, cache_root: PathBuf) -> Self {
|
||||
self.cache_root = Some(cache_root);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn codex_home(&self) -> &Path {
|
||||
&self.codex_home
|
||||
}
|
||||
|
||||
pub fn package(&self) -> &P {
|
||||
&self.package
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: ManagedPackage> PackageManagerConfig<P> {
|
||||
pub fn cache_root(&self) -> PathBuf {
|
||||
self.cache_root.clone().unwrap_or_else(|| {
|
||||
self.codex_home.join(
|
||||
self.package
|
||||
.default_cache_root_relative()
|
||||
.replace('/', std::path::MAIN_SEPARATOR_STR),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PackageManager<P> {
|
||||
client: Client,
|
||||
config: PackageManagerConfig<P>,
|
||||
}
|
||||
|
||||
impl<P> PackageManager<P> {
|
||||
pub fn new(config: PackageManagerConfig<P>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_client(config: PackageManagerConfig<P>, client: Client) -> Self {
|
||||
Self { client, config }
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &PackageManagerConfig<P> {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: ManagedPackage> PackageManager<P> {
|
||||
pub async fn resolve_cached(&self) -> Result<Option<P::Installed>, P::Error> {
|
||||
let platform = PackagePlatform::detect_current().map_err(P::Error::from)?;
|
||||
let install_dir = self
|
||||
.config
|
||||
.package()
|
||||
.install_dir(&self.config.cache_root(), platform);
|
||||
if !fs::try_exists(&install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read {}", install_dir.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let package = self
|
||||
.config
|
||||
.package()
|
||||
.load_installed(install_dir, platform)?;
|
||||
if self.config.package().installed_version(&package) != self.config.package().version() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(package))
|
||||
}
|
||||
|
||||
pub async fn ensure_installed(&self) -> Result<P::Installed, P::Error> {
|
||||
if let Some(package) = self.resolve_cached().await? {
|
||||
return Ok(package);
|
||||
}
|
||||
|
||||
let platform = PackagePlatform::detect_current().map_err(P::Error::from)?;
|
||||
let manifest = self.fetch_release_manifest().await?;
|
||||
if self.config.package().release_version(&manifest) != self.config.package().version() {
|
||||
return Err(PackageManagerError::UnexpectedPackageVersion {
|
||||
expected: self.config.package().version().to_string(),
|
||||
actual: self.config.package().release_version(&manifest).to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let platform_archive = self
|
||||
.config
|
||||
.package()
|
||||
.platform_archive(&manifest, platform)?;
|
||||
let archive_url = self
|
||||
.config
|
||||
.package()
|
||||
.archive_url(&platform_archive)
|
||||
.map_err(P::Error::from)?;
|
||||
let archive_bytes = self.download_bytes(&archive_url).await?;
|
||||
verify_sha256(&archive_bytes, &platform_archive.sha256).map_err(P::Error::from)?;
|
||||
|
||||
let install_dir = self
|
||||
.config
|
||||
.package()
|
||||
.install_dir(&self.config.cache_root(), platform);
|
||||
if fs::try_exists(&install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read {}", install_dir.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
{
|
||||
fs::remove_dir_all(&install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to remove {}", install_dir.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
}
|
||||
|
||||
let cache_root = self.config.cache_root();
|
||||
fs::create_dir_all(&cache_root)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", cache_root.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
|
||||
let staging_dir = tempdir_in(&cache_root)
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!(
|
||||
"failed to create staging directory in {}",
|
||||
cache_root.display()
|
||||
),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
let archive_path = staging_dir.path().join(&platform_archive.archive);
|
||||
fs::write(&archive_path, &archive_bytes)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to write {}", archive_path.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
let extraction_root = staging_dir.path().join("extract");
|
||||
fs::create_dir_all(&extraction_root)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", extraction_root.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
|
||||
extract_archive(&archive_path, &extraction_root, platform_archive.format)
|
||||
.map_err(P::Error::from)?;
|
||||
let extracted_root = self
|
||||
.config
|
||||
.package()
|
||||
.detect_extracted_root(&extraction_root)?;
|
||||
let package = self
|
||||
.config
|
||||
.package()
|
||||
.load_installed(extracted_root.clone(), platform)?;
|
||||
if self.config.package().installed_version(&package) != self.config.package().version() {
|
||||
return Err(PackageManagerError::UnexpectedPackageVersion {
|
||||
expected: self.config.package().version().to_string(),
|
||||
actual: self
|
||||
.config
|
||||
.package()
|
||||
.installed_version(&package)
|
||||
.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
if let Some(parent) = install_dir.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", parent.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
}
|
||||
|
||||
fs::rename(&extracted_root, &install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!(
|
||||
"failed to move {} to {}",
|
||||
extracted_root.display(),
|
||||
install_dir.display()
|
||||
),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
|
||||
self.config.package().load_installed(install_dir, platform)
|
||||
}
|
||||
|
||||
async fn fetch_release_manifest(&self) -> Result<P::ReleaseManifest, P::Error> {
|
||||
let manifest_url = self
|
||||
.config
|
||||
.package()
|
||||
.manifest_url()
|
||||
.map_err(P::Error::from)?;
|
||||
let response = self
|
||||
.client
|
||||
.get(manifest_url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to fetch {manifest_url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
.error_for_status()
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("manifest request failed for {manifest_url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
|
||||
response
|
||||
.json::<P::ReleaseManifest>()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to decode manifest from {manifest_url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)
|
||||
}
|
||||
|
||||
async fn download_bytes(&self, url: &Url) -> Result<Vec<u8>, P::Error> {
|
||||
let response = self
|
||||
.client
|
||||
.get(url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to download {url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
.error_for_status()
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("archive request failed for {url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to read response body for {url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ManagedPackage: Clone {
|
||||
type Error: From<PackageManagerError>;
|
||||
type Installed: Clone;
|
||||
type ReleaseManifest: DeserializeOwned;
|
||||
|
||||
fn default_cache_root_relative(&self) -> &str;
|
||||
fn version(&self) -> &str;
|
||||
fn manifest_url(&self) -> Result<Url, PackageManagerError>;
|
||||
fn archive_url(&self, archive: &PackageReleaseArchive) -> Result<Url, PackageManagerError>;
|
||||
fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str;
|
||||
fn platform_archive(
|
||||
&self,
|
||||
manifest: &Self::ReleaseManifest,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<PackageReleaseArchive, Self::Error>;
|
||||
fn install_dir(&self, cache_root: &Path, platform: PackagePlatform) -> PathBuf;
|
||||
fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str;
|
||||
fn load_installed(
|
||||
&self,
|
||||
root_dir: PathBuf,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<Self::Installed, Self::Error>;
|
||||
|
||||
fn detect_extracted_root(&self, extraction_root: &Path) -> Result<PathBuf, Self::Error> {
|
||||
detect_single_package_root(extraction_root).map_err(Self::Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PackagePlatform {
|
||||
DarwinArm64,
|
||||
DarwinX64,
|
||||
LinuxArm64,
|
||||
LinuxX64,
|
||||
WindowsArm64,
|
||||
WindowsX64,
|
||||
}
|
||||
|
||||
impl PackagePlatform {
|
||||
pub fn detect_current() -> Result<Self, PackageManagerError> {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("macos", "aarch64") | ("macos", "arm64") => Ok(Self::DarwinArm64),
|
||||
("macos", "x86_64") => Ok(Self::DarwinX64),
|
||||
("linux", "aarch64") | ("linux", "arm64") => Ok(Self::LinuxArm64),
|
||||
("linux", "x86_64") => Ok(Self::LinuxX64),
|
||||
("windows", "aarch64") | ("windows", "arm64") => Ok(Self::WindowsArm64),
|
||||
("windows", "x86_64") => Ok(Self::WindowsX64),
|
||||
(os, arch) => Err(PackageManagerError::UnsupportedPlatform {
|
||||
os: os.to_string(),
|
||||
arch: arch.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::DarwinArm64 => "darwin-arm64",
|
||||
Self::DarwinX64 => "darwin-x64",
|
||||
Self::LinuxArm64 => "linux-arm64",
|
||||
Self::LinuxX64 => "linux-x64",
|
||||
Self::WindowsArm64 => "windows-arm64",
|
||||
Self::WindowsX64 => "windows-x64",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||
pub struct PackageReleaseArchive {
|
||||
pub archive: String,
|
||||
pub sha256: String,
|
||||
pub format: ArchiveFormat,
|
||||
pub size_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||
pub enum ArchiveFormat {
|
||||
#[serde(rename = "zip")]
|
||||
Zip,
|
||||
#[serde(rename = "tar.gz")]
|
||||
TarGz,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PackageManagerError {
|
||||
#[error("unsupported platform: {os}-{arch}")]
|
||||
UnsupportedPlatform { os: String, arch: String },
|
||||
#[error("invalid release base url")]
|
||||
InvalidBaseUrl(#[source] url::ParseError),
|
||||
#[error("{context}")]
|
||||
Http {
|
||||
context: String,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
#[error("{context}")]
|
||||
Io {
|
||||
context: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("missing platform entry `{0}` in release manifest")]
|
||||
MissingPlatform(String),
|
||||
#[error("unexpected package version: expected `{expected}`, got `{actual}`")]
|
||||
UnexpectedPackageVersion { expected: String, actual: String },
|
||||
#[error("checksum mismatch: expected `{expected}`, got `{actual}`")]
|
||||
ChecksumMismatch { expected: String, actual: String },
|
||||
#[error("archive extraction failed: {0}")]
|
||||
ArchiveExtraction(String),
|
||||
#[error("archive did not contain a package root with manifest.json under {0}")]
|
||||
MissingPackageRoot(PathBuf),
|
||||
}
|
||||
|
||||
pub fn detect_single_package_root(extraction_root: &Path) -> Result<PathBuf, PackageManagerError> {
|
||||
let direct_manifest = extraction_root.join("manifest.json");
|
||||
if direct_manifest.exists() {
|
||||
return Ok(extraction_root.to_path_buf());
|
||||
}
|
||||
|
||||
let mut directory_candidates = Vec::new();
|
||||
for entry in std::fs::read_dir(extraction_root).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read {}", extraction_root.display()),
|
||||
source,
|
||||
})? {
|
||||
let entry = entry.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read entry in {}", extraction_root.display()),
|
||||
source,
|
||||
})?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
directory_candidates.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if directory_candidates.len() == 1 {
|
||||
let candidate = &directory_candidates[0];
|
||||
if candidate.join("manifest.json").exists() {
|
||||
return Ok(candidate.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Err(PackageManagerError::MissingPackageRoot(
|
||||
extraction_root.to_path_buf(),
|
||||
))
|
||||
}
|
||||
|
||||
fn verify_sha256(bytes: &[u8], expected: &str) -> Result<(), PackageManagerError> {
|
||||
let actual = format!("{:x}", Sha256::digest(bytes));
|
||||
if actual == expected.to_ascii_lowercase() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(PackageManagerError::ChecksumMismatch {
|
||||
expected: expected.to_string(),
|
||||
actual,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_archive(
|
||||
archive_path: &Path,
|
||||
destination: &Path,
|
||||
format: ArchiveFormat,
|
||||
) -> Result<(), PackageManagerError> {
|
||||
match format {
|
||||
ArchiveFormat::Zip => extract_zip_archive(archive_path, destination),
|
||||
ArchiveFormat::TarGz => extract_tar_gz_archive(archive_path, destination),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_zip_archive(archive_path: &Path, destination: &Path) -> Result<(), PackageManagerError> {
|
||||
let file = File::open(archive_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to open {}", archive_path.display()),
|
||||
source,
|
||||
})?;
|
||||
let mut archive = ZipArchive::new(file)
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
let Some(relative_path) = entry.enclosed_name() else {
|
||||
return Err(PackageManagerError::ArchiveExtraction(format!(
|
||||
"zip entry `{}` escapes extraction root",
|
||||
entry.name()
|
||||
)));
|
||||
};
|
||||
let output_path = destination.join(relative_path);
|
||||
if entry.is_dir() {
|
||||
std::fs::create_dir_all(&output_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", output_path.display()),
|
||||
source,
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", parent.display()),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let mut output = File::create(&output_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", output_path.display()),
|
||||
source,
|
||||
})?;
|
||||
std::io::copy(&mut entry, &mut output).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to write {}", output_path.display()),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar_gz_archive(
|
||||
archive_path: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<(), PackageManagerError> {
|
||||
let file = File::open(archive_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to open {}", archive_path.display()),
|
||||
source,
|
||||
})?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = Archive::new(decoder);
|
||||
for entry in archive
|
||||
.entries()
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?
|
||||
{
|
||||
let mut entry =
|
||||
entry.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
let path = entry
|
||||
.path()
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
let output_path = safe_extract_path(destination, path.as_ref())?;
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", parent.display()),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
entry
|
||||
.unpack(&output_path)
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn safe_extract_path(root: &Path, relative_path: &Path) -> Result<PathBuf, PackageManagerError> {
|
||||
let mut clean_relative = PathBuf::new();
|
||||
for component in relative_path.components() {
|
||||
match component {
|
||||
Component::Normal(segment) => clean_relative.push(segment),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||||
return Err(PackageManagerError::ArchiveExtraction(format!(
|
||||
"entry `{}` escapes extraction root",
|
||||
relative_path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if clean_relative.as_os_str().is_empty() {
|
||||
return Err(PackageManagerError::ArchiveExtraction(
|
||||
"archive entry had an empty path".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(root.join(clean_relative))
|
||||
}
|
||||
mod archive;
|
||||
mod config;
|
||||
mod error;
|
||||
mod manager;
|
||||
mod package;
|
||||
mod platform;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Cursor;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
mod tests;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct TestPackage {
|
||||
base_url: Url,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct TestReleaseManifest {
|
||||
package_version: String,
|
||||
platforms: BTreeMap<String, PackageReleaseArchive>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct TestInstalledPackage {
|
||||
version: String,
|
||||
platform: PackagePlatform,
|
||||
root_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ManagedPackage for TestPackage {
|
||||
type Error = PackageManagerError;
|
||||
type Installed = TestInstalledPackage;
|
||||
type ReleaseManifest = TestReleaseManifest;
|
||||
|
||||
fn default_cache_root_relative(&self) -> &str {
|
||||
"packages/test-package"
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
&self.version
|
||||
}
|
||||
|
||||
fn manifest_url(&self) -> Result<Url, PackageManagerError> {
|
||||
self.base_url
|
||||
.join(&format!("test-package-v{}-manifest.json", self.version))
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
|
||||
fn archive_url(&self, archive: &PackageReleaseArchive) -> Result<Url, PackageManagerError> {
|
||||
self.base_url
|
||||
.join(&archive.archive)
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
|
||||
fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str {
|
||||
&manifest.package_version
|
||||
}
|
||||
|
||||
fn platform_archive(
|
||||
&self,
|
||||
manifest: &Self::ReleaseManifest,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<PackageReleaseArchive, Self::Error> {
|
||||
manifest
|
||||
.platforms
|
||||
.get(platform.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| PackageManagerError::MissingPlatform(platform.as_str().to_string()))
|
||||
}
|
||||
|
||||
fn install_dir(&self, cache_root: &Path, platform: PackagePlatform) -> PathBuf {
|
||||
cache_root.join(self.version()).join(platform.as_str())
|
||||
}
|
||||
|
||||
fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str {
|
||||
&package.version
|
||||
}
|
||||
|
||||
fn load_installed(
|
||||
&self,
|
||||
root_dir: PathBuf,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<Self::Installed, Self::Error> {
|
||||
let version =
|
||||
std::fs::read_to_string(root_dir.join("manifest.json")).map_err(|source| {
|
||||
PackageManagerError::Io {
|
||||
context: format!(
|
||||
"failed to read {}",
|
||||
root_dir.join("manifest.json").display()
|
||||
),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
Ok(TestInstalledPackage {
|
||||
version: version.trim().to_string(),
|
||||
platform,
|
||||
root_dir,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_downloads_and_extracts_zip_package() {
|
||||
let server = MockServer::start().await;
|
||||
let version = "0.1.0";
|
||||
let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str());
|
||||
let archive_bytes = build_zip_archive(version);
|
||||
let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes));
|
||||
let manifest = serde_json::json!({
|
||||
"package_version": version,
|
||||
"platforms": {
|
||||
platform.as_str(): {
|
||||
"archive": archive_name,
|
||||
"sha256": archive_sha,
|
||||
"format": "zip",
|
||||
"size_bytes": archive_bytes.len(),
|
||||
}
|
||||
}
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/test-package-v{version}-manifest.json")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{archive_name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let package = TestPackage {
|
||||
base_url: Url::parse(&format!("{}/", server.uri()))
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
version: version.to_string(),
|
||||
};
|
||||
let manager = PackageManager::new(PackageManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
package,
|
||||
));
|
||||
|
||||
let installed = manager
|
||||
.ensure_installed()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(
|
||||
installed,
|
||||
TestInstalledPackage {
|
||||
version: version.to_string(),
|
||||
platform,
|
||||
root_dir: codex_home
|
||||
.path()
|
||||
.join("packages")
|
||||
.join("test-package")
|
||||
.join(version)
|
||||
.join(platform.as_str()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tar_gz_extraction_supports_default_package_root_detection() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_path = temp.path().join("package.tar.gz");
|
||||
let extraction_root = temp.path().join("extract");
|
||||
std::fs::create_dir_all(&extraction_root).unwrap_or_else(|error| panic!("{error}"));
|
||||
write_tar_gz_archive(&archive_path, "0.2.0");
|
||||
|
||||
extract_archive(&archive_path, &extraction_root, ArchiveFormat::TarGz)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
let package_root =
|
||||
detect_single_package_root(&extraction_root).unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert!(package_root.join("manifest.json").exists());
|
||||
}
|
||||
|
||||
fn build_zip_archive(version: &str) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut zip = ZipWriter::new(&mut bytes);
|
||||
let options = SimpleFileOptions::default();
|
||||
zip.start_file("test-package/manifest.json", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(version.as_bytes())
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
fn write_tar_gz_archive(archive_path: &Path, version: &str) {
|
||||
let file = File::create(archive_path).unwrap_or_else(|error| panic!("{error}"));
|
||||
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||
let mut builder = tar::Builder::new(encoder);
|
||||
|
||||
append_tar_file(
|
||||
&mut builder,
|
||||
"test-package/manifest.json",
|
||||
version.as_bytes(),
|
||||
);
|
||||
builder.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
|
||||
fn append_tar_file(
|
||||
builder: &mut tar::Builder<flate2::write::GzEncoder<File>>,
|
||||
path: &str,
|
||||
contents: &[u8],
|
||||
) {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, path, contents)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
}
|
||||
pub use archive::ArchiveFormat;
|
||||
pub use archive::PackageReleaseArchive;
|
||||
pub use config::PackageManagerConfig;
|
||||
pub use error::PackageManagerError;
|
||||
pub use manager::PackageManager;
|
||||
pub use package::ManagedPackage;
|
||||
pub use platform::PackagePlatform;
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
use crate::ManagedPackage;
|
||||
use crate::PackageManagerConfig;
|
||||
use crate::PackageManagerError;
|
||||
use crate::PackagePlatform;
|
||||
use crate::archive::extract_archive;
|
||||
use crate::archive::verify_archive_size;
|
||||
use crate::archive::verify_sha256;
|
||||
use fd_lock::RwLock as FileRwLock;
|
||||
use reqwest::Client;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir_in;
|
||||
use tokio::fs;
|
||||
use tokio::time::sleep;
|
||||
use url::Url;
|
||||
|
||||
const INSTALL_LOCK_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Fetches and installs a versioned package into a shared cache directory.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PackageManager<P> {
|
||||
client: Client,
|
||||
config: PackageManagerConfig<P>,
|
||||
}
|
||||
|
||||
impl<P> PackageManager<P> {
|
||||
/// Creates a manager with a default `reqwest` client.
|
||||
pub fn new(config: PackageManagerConfig<P>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a manager with a caller-provided HTTP client.
|
||||
pub fn with_client(config: PackageManagerConfig<P>, client: Client) -> Self {
|
||||
Self { client, config }
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: ManagedPackage> PackageManager<P> {
|
||||
/// Resolves a valid cached install for the current platform, if one exists.
|
||||
pub async fn resolve_cached(&self) -> Result<Option<P::Installed>, P::Error> {
|
||||
let platform = PackagePlatform::detect_current().map_err(P::Error::from)?;
|
||||
let install_dir = self
|
||||
.config
|
||||
.package
|
||||
.install_dir(&self.config.cache_root(), platform);
|
||||
self.resolve_cached_at(platform, install_dir).await
|
||||
}
|
||||
|
||||
/// Ensures the requested package is installed for the current platform.
|
||||
pub async fn ensure_installed(&self) -> Result<P::Installed, P::Error> {
|
||||
// Fast path: most calls should resolve an already validated cache entry
|
||||
// without touching the network or the install lock.
|
||||
if let Some(package) = self.resolve_cached().await? {
|
||||
return Ok(package);
|
||||
}
|
||||
|
||||
let platform = PackagePlatform::detect_current().map_err(P::Error::from)?;
|
||||
let cache_root = self.config.cache_root();
|
||||
let install_dir = self.config.package.install_dir(&cache_root, platform);
|
||||
if let Some(package) = self
|
||||
.resolve_cached_at(platform, install_dir.clone())
|
||||
.await?
|
||||
{
|
||||
return Ok(package);
|
||||
}
|
||||
|
||||
if let Some(parent) = install_dir.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", parent.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
}
|
||||
|
||||
let lock_path = install_dir.with_extension("lock");
|
||||
let lock_file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to open {}", lock_path.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
let mut install_lock = FileRwLock::new(lock_file);
|
||||
let _install_guard = loop {
|
||||
match install_lock.try_write() {
|
||||
Ok(guard) => break guard,
|
||||
Err(source) if source.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
sleep(INSTALL_LOCK_POLL_INTERVAL).await;
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(PackageManagerError::Io {
|
||||
context: format!("failed to lock {}", lock_path.display()),
|
||||
source,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Another process may have finished the install while we were waiting
|
||||
// on the lock, so re-check before doing any download or extraction work.
|
||||
if let Some(package) = self
|
||||
.resolve_cached_at(platform, install_dir.clone())
|
||||
.await?
|
||||
{
|
||||
return Ok(package);
|
||||
}
|
||||
|
||||
let manifest = self.fetch_release_manifest().await?;
|
||||
if self.config.package.release_version(&manifest) != self.config.package.version() {
|
||||
return Err(PackageManagerError::UnexpectedPackageVersion {
|
||||
expected: self.config.package.version().to_string(),
|
||||
actual: self.config.package.release_version(&manifest).to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
fs::create_dir_all(&cache_root)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", cache_root.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
let staging_root = cache_root.join(".staging");
|
||||
fs::create_dir_all(&staging_root)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", staging_root.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
|
||||
// Everything below happens in a disposable staging area until the
|
||||
// extracted package has passed package-specific validation.
|
||||
let platform_archive = self.config.package.platform_archive(&manifest, platform)?;
|
||||
let archive_url = self
|
||||
.config
|
||||
.package
|
||||
.archive_url(&platform_archive)
|
||||
.map_err(P::Error::from)?;
|
||||
let archive_bytes = self.download_bytes(&archive_url).await?;
|
||||
verify_archive_size(&archive_bytes, platform_archive.size_bytes).map_err(P::Error::from)?;
|
||||
verify_sha256(&archive_bytes, &platform_archive.sha256).map_err(P::Error::from)?;
|
||||
|
||||
let staging_dir = tempdir_in(&staging_root)
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!(
|
||||
"failed to create staging directory in {}",
|
||||
staging_root.display()
|
||||
),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
let archive_path = staging_dir.path().join(&platform_archive.archive);
|
||||
fs::write(&archive_path, &archive_bytes)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to write {}", archive_path.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
let extraction_root = staging_dir.path().join("extract");
|
||||
fs::create_dir_all(&extraction_root)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", extraction_root.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
|
||||
extract_archive(&archive_path, &extraction_root, platform_archive.format)
|
||||
.map_err(P::Error::from)?;
|
||||
let extracted_root = self
|
||||
.config
|
||||
.package
|
||||
.detect_extracted_root(&extraction_root)?;
|
||||
let package = self
|
||||
.config
|
||||
.package
|
||||
.load_installed(extracted_root.clone(), platform)?;
|
||||
if self.config.package.installed_version(&package) != self.config.package.version() {
|
||||
return Err(PackageManagerError::UnexpectedPackageVersion {
|
||||
expected: self.config.package.version().to_string(),
|
||||
actual: self.config.package.installed_version(&package).to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
if let Some(parent) = install_dir.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", parent.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
}
|
||||
|
||||
// Promotion is intentionally two-phase: move the old install aside,
|
||||
// then attempt an atomic rename of the staged tree into place.
|
||||
// If promotion fails, restore the previous install before returning.
|
||||
let replaced_install_dir = quarantine_existing_install(&install_dir)
|
||||
.await
|
||||
.map_err(P::Error::from)?;
|
||||
let promotion = promote_staged_install(&extracted_root, &install_dir).await;
|
||||
if let Err(error) = promotion {
|
||||
// If another process won the race after we staged our copy, prefer
|
||||
// the now-installed cache entry and clean up our quarantined copy.
|
||||
if matches!(
|
||||
&error,
|
||||
PackageManagerError::Io { source, .. }
|
||||
if matches!(
|
||||
source.kind(),
|
||||
std::io::ErrorKind::AlreadyExists
|
||||
| std::io::ErrorKind::DirectoryNotEmpty
|
||||
)
|
||||
) && let Some(package) = self
|
||||
.resolve_cached_at(platform, install_dir.clone())
|
||||
.await?
|
||||
{
|
||||
if let Some(replaced_install_dir) = replaced_install_dir {
|
||||
let _ = fs::remove_dir_all(replaced_install_dir).await;
|
||||
}
|
||||
return Ok(package);
|
||||
}
|
||||
|
||||
restore_quarantined_install(&install_dir, replaced_install_dir.as_deref(), &error)
|
||||
.await
|
||||
.map_err(P::Error::from)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
// Validate from the final install path before deleting the quarantined
|
||||
// previous install. Some packages may only fully validate once the
|
||||
// promoted tree is in place at its real cache location.
|
||||
let package = match self
|
||||
.config
|
||||
.package
|
||||
.load_installed(install_dir.clone(), platform)
|
||||
{
|
||||
Ok(package) => package,
|
||||
Err(error) => {
|
||||
if let Some(replaced_install_dir) = replaced_install_dir.as_deref() {
|
||||
// Final validation failed after promotion, so discard the
|
||||
// broken install and restore the last known-good copy.
|
||||
if fs::try_exists(&install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read {}", install_dir.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
{
|
||||
fs::remove_dir_all(&install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!(
|
||||
"failed to remove invalid install {} after final validation failed",
|
||||
install_dir.display()
|
||||
),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
}
|
||||
fs::rename(replaced_install_dir, &install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!(
|
||||
"failed to restore {} from {} after final validation failed",
|
||||
install_dir.display(),
|
||||
replaced_install_dir.display()
|
||||
),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(replaced_install_dir) = replaced_install_dir {
|
||||
let _ = fs::remove_dir_all(replaced_install_dir).await;
|
||||
}
|
||||
|
||||
Ok(package)
|
||||
}
|
||||
|
||||
async fn resolve_cached_at(
|
||||
&self,
|
||||
platform: PackagePlatform,
|
||||
install_dir: PathBuf,
|
||||
) -> Result<Option<P::Installed>, P::Error> {
|
||||
if !fs::try_exists(&install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read {}", install_dir.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let package = match self.config.package.load_installed(install_dir, platform) {
|
||||
Ok(package) => package,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if self.config.package.installed_version(&package) != self.config.package.version() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(package))
|
||||
}
|
||||
|
||||
async fn fetch_release_manifest(&self) -> Result<P::ReleaseManifest, P::Error> {
|
||||
let manifest_url = self.config.package.manifest_url().map_err(P::Error::from)?;
|
||||
let response = self
|
||||
.client
|
||||
.get(manifest_url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to fetch {manifest_url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
.error_for_status()
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("manifest request failed for {manifest_url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
|
||||
response
|
||||
.json::<P::ReleaseManifest>()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to decode manifest from {manifest_url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)
|
||||
}
|
||||
|
||||
async fn download_bytes(&self, url: &Url) -> Result<Vec<u8>, P::Error> {
|
||||
let response = self
|
||||
.client
|
||||
.get(url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to download {url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
.error_for_status()
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("archive request failed for {url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to read response body for {url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn quarantine_existing_install(
|
||||
install_dir: &Path,
|
||||
) -> Result<Option<PathBuf>, PackageManagerError> {
|
||||
if !fs::try_exists(install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read {}", install_dir.display()),
|
||||
source,
|
||||
})?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let install_name = install_dir.file_name().ok_or_else(|| {
|
||||
PackageManagerError::ArchiveExtraction(format!(
|
||||
"install path `{}` has no terminal component",
|
||||
install_dir.display()
|
||||
))
|
||||
})?;
|
||||
let install_name = install_name.to_string_lossy();
|
||||
let mut suffix = 0u32;
|
||||
loop {
|
||||
let quarantined_path = install_dir.with_file_name(format!(
|
||||
".{install_name}.replaced-{}-{suffix}",
|
||||
std::process::id()
|
||||
));
|
||||
match fs::rename(install_dir, &quarantined_path).await {
|
||||
Ok(()) => return Ok(Some(quarantined_path)),
|
||||
Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
suffix += 1;
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(PackageManagerError::Io {
|
||||
context: format!(
|
||||
"failed to quarantine {} to {}",
|
||||
install_dir.display(),
|
||||
quarantined_path.display()
|
||||
),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn promote_staged_install(
|
||||
extracted_root: &Path,
|
||||
install_dir: &Path,
|
||||
) -> Result<(), PackageManagerError> {
|
||||
fs::rename(extracted_root, install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!(
|
||||
"failed to move {} to {}",
|
||||
extracted_root.display(),
|
||||
install_dir.display()
|
||||
),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_quarantined_install(
|
||||
install_dir: &Path,
|
||||
quarantined_install_dir: Option<&Path>,
|
||||
promotion_error: &PackageManagerError,
|
||||
) -> Result<(), PackageManagerError> {
|
||||
let Some(quarantined_install_dir) = quarantined_install_dir else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
fs::rename(quarantined_install_dir, install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!(
|
||||
"{promotion_error}; failed to restore {} from {}",
|
||||
install_dir.display(),
|
||||
quarantined_install_dir.display()
|
||||
),
|
||||
source,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use crate::PackageManagerError;
|
||||
use crate::PackagePlatform;
|
||||
use crate::PackageReleaseArchive;
|
||||
use crate::archive::detect_single_package_root;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
|
||||
/// Describes how a specific package is located, validated, and loaded.
|
||||
///
|
||||
/// Implementations should treat this trait as the package manager contract:
|
||||
///
|
||||
/// - [`Self::install_dir`] should resolve to a directory unique to the package version and
|
||||
/// platform so concurrent versions never overwrite each other.
|
||||
/// - [`Self::load_installed`] should fully validate whatever "installed" means for the package,
|
||||
/// because cache resolution trusts a successful load as a valid install.
|
||||
/// - The default [`Self::detect_extracted_root`] implementation expects the extracted archive to
|
||||
/// contain a `manifest.json` at the package root or a single top-level directory that does.
|
||||
pub trait ManagedPackage: Clone {
|
||||
/// Error type surfaced by package-specific loading and validation.
|
||||
type Error: From<PackageManagerError>;
|
||||
|
||||
/// The fully loaded package instance returned to callers.
|
||||
type Installed: Clone;
|
||||
|
||||
/// The decoded release manifest fetched from the remote source.
|
||||
type ReleaseManifest: DeserializeOwned;
|
||||
|
||||
/// Returns the default cache root relative to Codex home.
|
||||
fn default_cache_root_relative(&self) -> &str;
|
||||
|
||||
/// Returns the requested package version.
|
||||
fn version(&self) -> &str;
|
||||
|
||||
/// Returns the manifest URL for the requested version.
|
||||
fn manifest_url(&self) -> Result<Url, PackageManagerError>;
|
||||
|
||||
/// Returns the archive download URL for a platform-specific manifest entry.
|
||||
fn archive_url(&self, archive: &PackageReleaseArchive) -> Result<Url, PackageManagerError>;
|
||||
|
||||
/// Returns the version string stored in the fetched release manifest.
|
||||
fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str;
|
||||
|
||||
/// Selects the archive to download for the current platform.
|
||||
fn platform_archive(
|
||||
&self,
|
||||
manifest: &Self::ReleaseManifest,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<PackageReleaseArchive, Self::Error>;
|
||||
|
||||
/// Returns the final install directory for the package version and platform.
|
||||
fn install_dir(&self, cache_root: &Path, platform: PackagePlatform) -> PathBuf;
|
||||
|
||||
/// Returns the version string encoded in a fully loaded installed package.
|
||||
fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str;
|
||||
|
||||
/// Loads and validates an installed package from disk.
|
||||
fn load_installed(
|
||||
&self,
|
||||
root_dir: PathBuf,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<Self::Installed, Self::Error>;
|
||||
|
||||
/// Resolves the extracted package root before the staged install is promoted.
|
||||
fn detect_extracted_root(&self, extraction_root: &Path) -> Result<PathBuf, Self::Error> {
|
||||
detect_single_package_root(extraction_root).map_err(Self::Error::from)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use crate::PackageManagerError;
|
||||
|
||||
/// Supported OS and CPU combinations for managed packages.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PackagePlatform {
|
||||
/// macOS on Apple Silicon.
|
||||
DarwinArm64,
|
||||
/// macOS on x86_64.
|
||||
DarwinX64,
|
||||
/// Linux on AArch64.
|
||||
LinuxArm64,
|
||||
/// Linux on x86_64.
|
||||
LinuxX64,
|
||||
/// Windows on AArch64.
|
||||
WindowsArm64,
|
||||
/// Windows on x86_64.
|
||||
WindowsX64,
|
||||
}
|
||||
|
||||
impl PackagePlatform {
|
||||
/// Detects the current process platform.
|
||||
pub fn detect_current() -> Result<Self, PackageManagerError> {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("macos", "aarch64") | ("macos", "arm64") => Ok(Self::DarwinArm64),
|
||||
("macos", "x86_64") => Ok(Self::DarwinX64),
|
||||
("linux", "aarch64") | ("linux", "arm64") => Ok(Self::LinuxArm64),
|
||||
("linux", "x86_64") => Ok(Self::LinuxX64),
|
||||
("windows", "aarch64") | ("windows", "arm64") => Ok(Self::WindowsArm64),
|
||||
("windows", "x86_64") => Ok(Self::WindowsX64),
|
||||
(os, arch) => Err(PackageManagerError::UnsupportedPlatform {
|
||||
os: os.to_string(),
|
||||
arch: arch.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the manifest/cache string for this platform.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::DarwinArm64 => "darwin-arm64",
|
||||
Self::DarwinX64 => "darwin-x64",
|
||||
Self::LinuxArm64 => "linux-arm64",
|
||||
Self::LinuxX64 => "linux-x64",
|
||||
Self::WindowsArm64 => "windows-arm64",
|
||||
Self::WindowsX64 => "windows-x64",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
use crate::ArchiveFormat;
|
||||
use crate::ManagedPackage;
|
||||
use crate::PackageManager;
|
||||
use crate::PackageManagerConfig;
|
||||
use crate::PackageManagerError;
|
||||
use crate::PackagePlatform;
|
||||
use crate::PackageReleaseArchive;
|
||||
use crate::archive::detect_single_package_root;
|
||||
use crate::archive::extract_archive;
|
||||
use crate::manager::promote_staged_install;
|
||||
use crate::manager::quarantine_existing_install;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Deserialize;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::File;
|
||||
use std::io::Cursor;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tar::Builder;
|
||||
use tar::EntryType;
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::Barrier;
|
||||
use url::Url;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct TestPackage {
|
||||
base_url: Url,
|
||||
version: String,
|
||||
fail_on_final_install_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct TestReleaseManifest {
|
||||
package_version: String,
|
||||
platforms: BTreeMap<String, PackageReleaseArchive>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct TestInstalledPackage {
|
||||
version: String,
|
||||
platform: PackagePlatform,
|
||||
root_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ManagedPackage for TestPackage {
|
||||
type Error = PackageManagerError;
|
||||
type Installed = TestInstalledPackage;
|
||||
type ReleaseManifest = TestReleaseManifest;
|
||||
|
||||
fn default_cache_root_relative(&self) -> &str {
|
||||
"packages/test-package"
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
&self.version
|
||||
}
|
||||
|
||||
fn manifest_url(&self) -> Result<Url, PackageManagerError> {
|
||||
self.base_url
|
||||
.join(&format!("test-package-v{}-manifest.json", self.version))
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
|
||||
fn archive_url(&self, archive: &PackageReleaseArchive) -> Result<Url, PackageManagerError> {
|
||||
self.base_url
|
||||
.join(&archive.archive)
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
|
||||
fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str {
|
||||
&manifest.package_version
|
||||
}
|
||||
|
||||
fn platform_archive(
|
||||
&self,
|
||||
manifest: &Self::ReleaseManifest,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<PackageReleaseArchive, Self::Error> {
|
||||
manifest
|
||||
.platforms
|
||||
.get(platform.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| PackageManagerError::MissingPlatform(platform.as_str().to_string()))
|
||||
}
|
||||
|
||||
fn install_dir(&self, cache_root: &Path, platform: PackagePlatform) -> PathBuf {
|
||||
cache_root.join(self.version()).join(platform.as_str())
|
||||
}
|
||||
|
||||
fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str {
|
||||
&package.version
|
||||
}
|
||||
|
||||
fn load_installed(
|
||||
&self,
|
||||
root_dir: PathBuf,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<Self::Installed, Self::Error> {
|
||||
if self.fail_on_final_install_dir
|
||||
&& root_dir
|
||||
.file_name()
|
||||
.is_some_and(|name| name == platform.as_str())
|
||||
{
|
||||
return Err(PackageManagerError::ArchiveExtraction(format!(
|
||||
"refusing final install dir {}",
|
||||
root_dir.display()
|
||||
)));
|
||||
}
|
||||
let manifest_path = root_dir.join("manifest.json");
|
||||
let version =
|
||||
std::fs::read_to_string(&manifest_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read {}", manifest_path.display()),
|
||||
source,
|
||||
})?;
|
||||
Ok(TestInstalledPackage {
|
||||
version: version.trim().to_string(),
|
||||
platform,
|
||||
root_dir,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_downloads_and_extracts_zip_package() {
|
||||
let server = MockServer::start().await;
|
||||
let version = "0.1.0";
|
||||
let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str());
|
||||
let archive_bytes = build_zip_archive(version);
|
||||
let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes));
|
||||
let manifest = serde_json::json!({
|
||||
"package_version": version,
|
||||
"platforms": {
|
||||
platform.as_str(): {
|
||||
"archive": archive_name,
|
||||
"sha256": archive_sha,
|
||||
"format": "zip",
|
||||
"size_bytes": archive_bytes.len(),
|
||||
}
|
||||
}
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/test-package-v{version}-manifest.json")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{archive_name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let package = TestPackage {
|
||||
base_url: Url::parse(&format!("{}/", server.uri()))
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
version: version.to_string(),
|
||||
fail_on_final_install_dir: false,
|
||||
};
|
||||
let manager = PackageManager::new(PackageManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
package,
|
||||
));
|
||||
|
||||
let installed = manager
|
||||
.ensure_installed()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(
|
||||
installed,
|
||||
TestInstalledPackage {
|
||||
version: version.to_string(),
|
||||
platform,
|
||||
root_dir: codex_home
|
||||
.path()
|
||||
.join("packages")
|
||||
.join("test-package")
|
||||
.join(version)
|
||||
.join(platform.as_str()),
|
||||
}
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let executable_mode = std::fs::metadata(installed.root_dir.join("bin/tool"))
|
||||
.unwrap_or_else(|error| panic!("{error}"))
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(executable_mode & 0o111, 0o111);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_cached_uses_custom_cache_root() {
|
||||
let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let cache_root = codex_home.path().join("custom-cache");
|
||||
let install_dir = cache_root.join("0.1.0").join(platform.as_str());
|
||||
std::fs::create_dir_all(&install_dir).unwrap_or_else(|error| panic!("{error}"));
|
||||
std::fs::write(install_dir.join("manifest.json"), "0.1.0")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
let manager = PackageManager::new(
|
||||
PackageManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
TestPackage {
|
||||
base_url: Url::parse("https://example.test/")
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
version: "0.1.0".to_string(),
|
||||
fail_on_final_install_dir: false,
|
||||
},
|
||||
)
|
||||
.with_cache_root(cache_root.clone()),
|
||||
);
|
||||
|
||||
let installed = manager
|
||||
.resolve_cached()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(
|
||||
installed,
|
||||
Some(TestInstalledPackage {
|
||||
version: "0.1.0".to_string(),
|
||||
platform,
|
||||
root_dir: cache_root.join("0.1.0").join(platform.as_str()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_replaces_invalid_cached_install() {
|
||||
let server = MockServer::start().await;
|
||||
let version = "0.1.0";
|
||||
let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str());
|
||||
let archive_bytes = build_zip_archive(version);
|
||||
let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes));
|
||||
let manifest = serde_json::json!({
|
||||
"package_version": version,
|
||||
"platforms": {
|
||||
platform.as_str(): {
|
||||
"archive": archive_name,
|
||||
"sha256": archive_sha,
|
||||
"format": "zip",
|
||||
"size_bytes": archive_bytes.len(),
|
||||
}
|
||||
}
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/test-package-v{version}-manifest.json")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{archive_name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let install_dir = codex_home
|
||||
.path()
|
||||
.join("packages")
|
||||
.join("test-package")
|
||||
.join(version)
|
||||
.join(platform.as_str());
|
||||
std::fs::create_dir_all(&install_dir).unwrap_or_else(|error| panic!("{error}"));
|
||||
std::fs::write(install_dir.join("broken.txt"), "stale")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
let manager = PackageManager::new(PackageManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
TestPackage {
|
||||
base_url: Url::parse(&format!("{}/", server.uri()))
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
version: version.to_string(),
|
||||
fail_on_final_install_dir: false,
|
||||
},
|
||||
));
|
||||
|
||||
let installed = manager
|
||||
.ensure_installed()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(installed.version, version);
|
||||
assert!(installed.root_dir.join("manifest.json").exists());
|
||||
assert!(!installed.root_dir.join("broken.txt").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_rejects_manifest_version_mismatch() {
|
||||
let server = MockServer::start().await;
|
||||
let version = "0.1.0";
|
||||
let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str());
|
||||
let manifest = serde_json::json!({
|
||||
"package_version": "0.2.0",
|
||||
"platforms": {
|
||||
platform.as_str(): {
|
||||
"archive": archive_name,
|
||||
"sha256": "deadbeef",
|
||||
"format": "zip",
|
||||
"size_bytes": 1,
|
||||
}
|
||||
}
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/test-package-v{version}-manifest.json")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let manager = PackageManager::new(PackageManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
TestPackage {
|
||||
base_url: Url::parse(&format!("{}/", server.uri()))
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
version: version.to_string(),
|
||||
fail_on_final_install_dir: false,
|
||||
},
|
||||
));
|
||||
|
||||
let error = manager
|
||||
.ensure_installed()
|
||||
.await
|
||||
.expect_err("manifest version mismatch should fail");
|
||||
assert!(matches!(
|
||||
error,
|
||||
PackageManagerError::UnexpectedPackageVersion { expected, actual }
|
||||
if expected == "0.1.0" && actual == "0.2.0"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_serializes_concurrent_installs() {
|
||||
let server = MockServer::start().await;
|
||||
let version = "0.1.0";
|
||||
let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str());
|
||||
let archive_bytes = build_zip_archive(version);
|
||||
let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes));
|
||||
let manifest = serde_json::json!({
|
||||
"package_version": version,
|
||||
"platforms": {
|
||||
platform.as_str(): {
|
||||
"archive": archive_name,
|
||||
"sha256": archive_sha,
|
||||
"format": "zip",
|
||||
"size_bytes": archive_bytes.len(),
|
||||
}
|
||||
}
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/test-package-v{version}-manifest.json")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{archive_name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let config = PackageManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
TestPackage {
|
||||
base_url: Url::parse(&format!("{}/", server.uri()))
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
version: version.to_string(),
|
||||
fail_on_final_install_dir: false,
|
||||
},
|
||||
);
|
||||
let manager_one = PackageManager::new(config.clone());
|
||||
let manager_two = PackageManager::new(config);
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let barrier_one = Arc::clone(&barrier);
|
||||
let barrier_two = Arc::clone(&barrier);
|
||||
|
||||
let (first, second) = tokio::join!(
|
||||
async {
|
||||
barrier_one.wait().await;
|
||||
manager_one.ensure_installed().await
|
||||
},
|
||||
async {
|
||||
barrier_two.wait().await;
|
||||
manager_two.ensure_installed().await
|
||||
}
|
||||
);
|
||||
|
||||
let first = first.unwrap_or_else(|error| panic!("{error}"));
|
||||
let second = second.unwrap_or_else(|error| panic!("{error}"));
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_rejects_unexpected_archive_size() {
|
||||
let server = MockServer::start().await;
|
||||
let version = "0.1.0";
|
||||
let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str());
|
||||
let archive_bytes = build_zip_archive(version);
|
||||
let actual_size = archive_bytes.len() as u64;
|
||||
let expected_size = (archive_bytes.len() + 1) as u64;
|
||||
let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes));
|
||||
let manifest = serde_json::json!({
|
||||
"package_version": version,
|
||||
"platforms": {
|
||||
platform.as_str(): {
|
||||
"archive": archive_name,
|
||||
"sha256": archive_sha,
|
||||
"format": "zip",
|
||||
"size_bytes": expected_size,
|
||||
}
|
||||
}
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/test-package-v{version}-manifest.json")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{archive_name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let manager = PackageManager::new(PackageManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
TestPackage {
|
||||
base_url: Url::parse(&format!("{}/", server.uri()))
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
version: version.to_string(),
|
||||
fail_on_final_install_dir: false,
|
||||
},
|
||||
));
|
||||
|
||||
let error = manager
|
||||
.ensure_installed()
|
||||
.await
|
||||
.expect_err("archive size mismatch should fail");
|
||||
assert!(matches!(
|
||||
error,
|
||||
PackageManagerError::UnexpectedArchiveSize { expected, actual }
|
||||
if expected == expected_size && actual == actual_size
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn staged_install_restore_keeps_previous_install_on_failed_promotion() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let install_dir = temp.path().join("install");
|
||||
let staged_dir = temp.path().join("missing-staged");
|
||||
std::fs::create_dir_all(&install_dir).unwrap_or_else(|error| panic!("{error}"));
|
||||
std::fs::write(install_dir.join("manifest.json"), "0.1.0")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
let quarantined = quarantine_existing_install(&install_dir)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
let promotion_error = promote_staged_install(&staged_dir, &install_dir)
|
||||
.await
|
||||
.expect_err("promotion should fail");
|
||||
crate::manager::restore_quarantined_install(
|
||||
&install_dir,
|
||||
quarantined.as_deref(),
|
||||
&promotion_error,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert!(install_dir.join("manifest.json").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(install_dir.join("manifest.json"))
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
"0.1.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_restores_previous_install_when_final_validation_fails() {
|
||||
let server = MockServer::start().await;
|
||||
let version = "0.1.0";
|
||||
let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str());
|
||||
let archive_bytes = build_zip_archive(version);
|
||||
let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes));
|
||||
let manifest = serde_json::json!({
|
||||
"package_version": version,
|
||||
"platforms": {
|
||||
platform.as_str(): {
|
||||
"archive": archive_name,
|
||||
"sha256": archive_sha,
|
||||
"format": "zip",
|
||||
"size_bytes": archive_bytes.len(),
|
||||
}
|
||||
}
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/test-package-v{version}-manifest.json")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{archive_name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let install_dir = codex_home
|
||||
.path()
|
||||
.join("packages")
|
||||
.join("test-package")
|
||||
.join(version)
|
||||
.join(platform.as_str());
|
||||
std::fs::create_dir_all(&install_dir).unwrap_or_else(|error| panic!("{error}"));
|
||||
std::fs::write(install_dir.join("manifest.json"), "0.0.9")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
let error = PackageManager::new(PackageManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
TestPackage {
|
||||
base_url: Url::parse(&format!("{}/", server.uri()))
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
version: version.to_string(),
|
||||
fail_on_final_install_dir: true,
|
||||
},
|
||||
))
|
||||
.ensure_installed()
|
||||
.await
|
||||
.expect_err("final validation should fail");
|
||||
|
||||
assert!(
|
||||
matches!(error, PackageManagerError::ArchiveExtraction(message) if message.contains("refusing final install dir"))
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(install_dir.join("manifest.json"))
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
"0.0.9"
|
||||
);
|
||||
assert!(
|
||||
!install_dir
|
||||
.parent()
|
||||
.unwrap_or_else(|| panic!("install dir should have a parent"))
|
||||
.read_dir()
|
||||
.unwrap_or_else(|error| panic!("{error}"))
|
||||
.any(|entry| {
|
||||
entry
|
||||
.unwrap_or_else(|error| panic!("{error}"))
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.contains(".replaced-")
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tar_gz_extraction_supports_default_package_root_detection() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_path = temp.path().join("package.tar.gz");
|
||||
let extraction_root = temp.path().join("extract");
|
||||
std::fs::create_dir_all(&extraction_root).unwrap_or_else(|error| panic!("{error}"));
|
||||
write_tar_gz_archive(&archive_path, "0.2.0");
|
||||
|
||||
extract_archive(&archive_path, &extraction_root, ArchiveFormat::TarGz)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
let package_root =
|
||||
detect_single_package_root(&extraction_root).unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert!(package_root.join("manifest.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tar_gz_extraction_rejects_symlinks() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_path = temp.path().join("package.tar.gz");
|
||||
let extraction_root = temp.path().join("extract");
|
||||
std::fs::create_dir_all(&extraction_root).unwrap_or_else(|error| panic!("{error}"));
|
||||
write_tar_gz_archive_with_symlink(&archive_path);
|
||||
|
||||
let error = extract_archive(&archive_path, &extraction_root, ArchiveFormat::TarGz)
|
||||
.expect_err("symlink entry should fail");
|
||||
assert!(
|
||||
matches!(error, PackageManagerError::ArchiveExtraction(message) if message.contains("unsupported type"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_extraction_rejects_parent_paths() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_path = temp.path().join("package.zip");
|
||||
let extraction_root = temp.path().join("extract");
|
||||
std::fs::create_dir_all(&extraction_root).unwrap_or_else(|error| panic!("{error}"));
|
||||
write_zip_archive_with_parent_path(&archive_path);
|
||||
|
||||
let error = extract_archive(&archive_path, &extraction_root, ArchiveFormat::Zip)
|
||||
.expect_err("parent path entry should fail");
|
||||
assert!(
|
||||
matches!(error, PackageManagerError::ArchiveExtraction(message) if message.contains("escapes extraction root"))
|
||||
);
|
||||
}
|
||||
|
||||
fn build_zip_archive(version: &str) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut zip = ZipWriter::new(&mut bytes);
|
||||
let options = SimpleFileOptions::default();
|
||||
zip.start_file("test-package/manifest.json", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(version.as_bytes())
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("test-package/bin/tool", options.unix_permissions(0o755))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"#!/bin/sh\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
fn write_zip_archive_with_parent_path(archive_path: &Path) {
|
||||
let file = File::create(archive_path).unwrap_or_else(|error| panic!("{error}"));
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default();
|
||||
zip.start_file("../escape.txt", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"escape")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
|
||||
fn write_tar_gz_archive(archive_path: &Path, version: &str) {
|
||||
let file = File::create(archive_path).unwrap_or_else(|error| panic!("{error}"));
|
||||
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||
let mut builder = Builder::new(encoder);
|
||||
|
||||
append_tar_file(
|
||||
&mut builder,
|
||||
"test-package/manifest.json",
|
||||
version.as_bytes(),
|
||||
);
|
||||
builder.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
|
||||
fn write_tar_gz_archive_with_symlink(archive_path: &Path) {
|
||||
let file = File::create(archive_path).unwrap_or_else(|error| panic!("{error}"));
|
||||
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||
let mut builder = Builder::new(encoder);
|
||||
|
||||
append_tar_file(&mut builder, "test-package/manifest.json", b"0.2.0");
|
||||
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(EntryType::Symlink);
|
||||
header.set_size(0);
|
||||
header.set_mode(0o777);
|
||||
header
|
||||
.set_link_name("/tmp/escape")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, "test-package/link", std::io::empty())
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
builder.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
|
||||
fn append_tar_file(
|
||||
builder: &mut Builder<flate2::write::GzEncoder<File>>,
|
||||
path: &str,
|
||||
contents: &[u8],
|
||||
) {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, path, contents)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use crate::protocol::SandboxPolicy;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -190,15 +191,35 @@ impl ExecApprovalRequestEvent {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "mode", rename_all = "snake_case")]
|
||||
#[ts(tag = "mode")]
|
||||
pub enum ElicitationRequest {
|
||||
Form {
|
||||
message: String,
|
||||
requested_schema: JsonValue,
|
||||
},
|
||||
Url {
|
||||
message: String,
|
||||
url: String,
|
||||
elicitation_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ElicitationRequest {
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::Form { message, .. } | Self::Url { message, .. } => message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
|
||||
pub struct ElicitationRequestEvent {
|
||||
pub server_name: String,
|
||||
#[ts(type = "string | number")]
|
||||
pub id: RequestId,
|
||||
pub message: String,
|
||||
// TODO: MCP servers can request we fill out a schema for the elicitation. We don't support
|
||||
// this yet.
|
||||
// pub requested_schema: ElicitRequestParamsRequestedSchema,
|
||||
pub request: ElicitationRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
|
||||
@@ -331,6 +331,9 @@ pub enum Op {
|
||||
request_id: RequestId,
|
||||
/// User's decision for the request.
|
||||
decision: ElicitationAction,
|
||||
/// Structured user input supplied for accepted elicitations.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<Value>,
|
||||
},
|
||||
|
||||
/// Resolve a request_user_input tool call.
|
||||
@@ -2217,6 +2220,8 @@ pub struct TurnContextNetworkItem {
|
||||
pub struct TurnContextItem {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub turn_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trace_id: Option<String>,
|
||||
pub cwd: PathBuf,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_date: Option<String>,
|
||||
@@ -3506,6 +3511,7 @@ mod tests {
|
||||
"summary": "auto",
|
||||
}))?;
|
||||
|
||||
assert_eq!(item.trace_id, None);
|
||||
assert_eq!(item.network, None);
|
||||
Ok(())
|
||||
}
|
||||
@@ -3514,6 +3520,7 @@ mod tests {
|
||||
fn turn_context_item_serializes_network_when_present() -> Result<()> {
|
||||
let item = TurnContextItem {
|
||||
turn_id: None,
|
||||
trace_id: None,
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
name: slides
|
||||
description: Build, edit, render, import, and export presentation decks with the preloaded @oai/artifact-tool JavaScript surface through the artifacts tool.
|
||||
metadata:
|
||||
short-description: Use the artifacts tool to create and edit slide decks in JavaScript
|
||||
---
|
||||
|
||||
# Slides
|
||||
|
||||
Use this skill when the user wants to create or modify presentation decks with the `artifacts` tool.
|
||||
|
||||
## Tool Contract
|
||||
|
||||
- Use the `artifacts` tool.
|
||||
- Send raw JavaScript only. Do not send JSON objects, quoted code, or markdown fences.
|
||||
- This tool runs plain JavaScript in Node, not TypeScript. Do not use type annotations, `type`, `interface`, or `import type`.
|
||||
- Do not write `import { ... } from "@oai/artifact-tool"`. The `@oai/artifact-tool` module surface is already preloaded on `globalThis`.
|
||||
- Named exports such as `Presentation`, `PresentationFile`, `FileBlob`, `AutoLayoutAlign`, and `AutoLayoutDirection` are available directly.
|
||||
- The full module is also available as `artifactTool`, `artifacts`, and `codexArtifacts`.
|
||||
- You may still import Node built-ins such as `node:fs/promises` when you need to write preview bytes to disk.
|
||||
- Save outputs under a user-visible path such as `artifacts/quarterly-update.pptx` or `artifacts/slide-1.png`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```js
|
||||
const presentation = Presentation.create({
|
||||
slideSize: { width: 960, height: 540 },
|
||||
});
|
||||
|
||||
const slide = presentation.slides.add();
|
||||
slide.background.fill = "background1";
|
||||
|
||||
const title = slide.shapes.add({
|
||||
geometry: "roundRect",
|
||||
position: { left: 80, top: 72, width: 800, height: 96 },
|
||||
fill: "accent1",
|
||||
});
|
||||
title.text = "Q2 Product Update";
|
||||
|
||||
const subtitle = slide.shapes.add({
|
||||
geometry: "rect",
|
||||
position: { left: 80, top: 196, width: 800, height: 48 },
|
||||
});
|
||||
subtitle.text = "Launch status, reliability, and next milestones";
|
||||
|
||||
const pptxBlob = await PresentationFile.exportPptx(presentation);
|
||||
await pptxBlob.save("artifacts/q2-product-update.pptx");
|
||||
```
|
||||
|
||||
## Runtime Guardrails
|
||||
|
||||
- Prefer `slide.elements.charts.add("line", { position: ... })` for charts. The runtime chart surface is element-based; `slide.charts.add(...)` is not the reliable entry point for authoring new charts in this skill.
|
||||
- After creating a chart element, set properties on the returned chart object:
|
||||
|
||||
```js
|
||||
const chart = slide.elements.charts.add("line", {
|
||||
position: { left: 80, top: 180, width: 640, height: 320 },
|
||||
});
|
||||
chart.title = "Horsepower";
|
||||
chart.categories = ["1964", "1973", "1989", "2024"];
|
||||
const series = chart.series.add("911");
|
||||
series.values = [130, 210, 247, 518];
|
||||
chart.hasLegend = false;
|
||||
```
|
||||
|
||||
- For local or fetched images that must survive preview rendering, embed bytes rather than passing only a file path or URL. The most reliable pattern is an `ArrayBuffer` plus `contentType`:
|
||||
|
||||
```js
|
||||
const fs = await import("node:fs/promises");
|
||||
const source = await fs.readFile("artifacts/porsche.jpg");
|
||||
const imageBuffer = source.buffer.slice(
|
||||
source.byteOffset,
|
||||
source.byteOffset + source.byteLength,
|
||||
);
|
||||
|
||||
slide.elements.images.add({
|
||||
blob: imageBuffer,
|
||||
contentType: "image/jpeg",
|
||||
position: { left: 500, top: 0, width: 460, height: 540 },
|
||||
fit: "cover",
|
||||
});
|
||||
```
|
||||
|
||||
- If you fetch an image in-script, save or convert it to bytes first, then pass the `ArrayBuffer` into `slide.elements.images.add(...)`. Do not assume `path`, `url`, `src`, or a Node `Buffer` will preview correctly.
|
||||
- PPTX export can still inherit layout placeholders such as `Title 1`, `Subtitle 2`, date/footer placeholders, or PowerPoint's `Click to add title` boxes. If the deck is meant to be fully custom, strip placeholder shapes before final export:
|
||||
|
||||
```js
|
||||
const placeholderNames = new Set([
|
||||
"Title 1",
|
||||
"Subtitle 2",
|
||||
"Date Placeholder 3",
|
||||
"Footer Placeholder 4",
|
||||
"Slide Number Placeholder 5",
|
||||
]);
|
||||
|
||||
for (const slide of presentation.slides.items) {
|
||||
const toDelete = slide.shapes.items.filter((shape) => {
|
||||
const name = shape.name ?? "";
|
||||
return placeholderNames.has(name) || Boolean(shape.placeholderType);
|
||||
});
|
||||
for (const shape of toDelete) {
|
||||
shape.delete();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- Create a new deck with `Presentation.create({ slideSize })`.
|
||||
- Import an existing deck with `await PresentationFile.importPptx(await FileBlob.load("deck.pptx"))`.
|
||||
- Add slides with `presentation.slides.add()` or `presentation.slides.insert({ after, layout })`.
|
||||
- Add content with `slide.shapes.add(...)`, `slide.tables.add(...)`, `slide.elements.charts.add(...)`, and `slide.elements.images.add(...)` when you need preview-safe embedded images.
|
||||
- Render a preview with `await presentation.export({ slide, format: "png", scale: 2 })`, then write `new Uint8Array(await blob.arrayBuffer())` with `node:fs/promises`.
|
||||
- Export a `.pptx` with `await PresentationFile.exportPptx(presentation)`.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Start with the smallest script that creates or imports the deck.
|
||||
- Do not begin by checking whether the local artifacts runtime package or cache exists. Assume the `artifacts` tool is ready and start authoring immediately; only investigate runtime installation or packaging if the tool fails before your slide code runs.
|
||||
- If the API surface is unclear, do a tiny probe first: create one slide, add one shape, set `text` or `textStyle`, export one PNG, and inspect the result before scaling up to the full deck.
|
||||
- Save the `.pptx` after meaningful milestones so the user can inspect output.
|
||||
- Prefer short copy and a reusable component system over text-heavy layouts; the preview loop is much faster than rescuing a dense slide after export.
|
||||
- Text boxes do not reliably auto-fit. If copy might wrap, give the shape extra height up front, then shorten the copy or enlarge the box until the rendered PNG shows clear padding on every edge.
|
||||
- Deliberately check text contrast against the actual fill or image behind it. Do not leave dark text on dark fills, light text on light fills, or any pairing that is hard to read at presentation distance.
|
||||
- Treat text containment as a hard requirement. No text should overlap other elements, sit underneath another object, spill outside its geometry, or run off the slide edge.
|
||||
- If text sits inside a box, make the alignment look intentional. Center it only when the layout calls for centered copy; otherwise choose left or right alignment that matches the composition and leaves balanced padding.
|
||||
- If you are using charts or external images, run a tiny end-to-end preview probe before building the full deck. One chart with one series and one embedded image is enough to validate the runtime surface.
|
||||
- If the deck should not use PowerPoint placeholders, inspect imported slide shapes for inherited layout items before the final export. Delete shapes whose names match built-in placeholder labels or that expose `placeholderType`.
|
||||
- If layout is repetitive, use `slide.autoLayout(...)` rather than hand-tuning every coordinate.
|
||||
- QA with rendered PNG previews before handoff. In practice this is a more reliable quick check than importing the generated `.pptx` back into the runtime and inspecting round-tripped objects.
|
||||
- Final QA means checking every rendered slide for contrast, intentional alignment, text superposition, clipped text, overflowing text, and inherited placeholder boxes. If text is hard to read against its background, if one text box overlaps another, if stacked text becomes hard to read, if any line touches a box edge, if text looks misaligned inside its box, or if PowerPoint shows `Click to add ...` placeholders, fix the layout or delete the inherited placeholder shapes and re-export before handoff.
|
||||
- When editing an existing file, load it first, mutate only the requested slides or elements, then export a new `.pptx`.
|
||||
|
||||
## Reference Map
|
||||
|
||||
- [`references/presentation.md`](./references/presentation.md) for the core `Presentation` and `PresentationFile` lifecycle.
|
||||
- [`references/auto-layout.md`](./references/auto-layout.md) for deterministic layout helpers and alignment enums.
|
||||
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "Slides"
|
||||
short_description: "Create and edit slide decks with the artifacts tool"
|
||||
@@ -0,0 +1,30 @@
|
||||
# Auto Layout
|
||||
|
||||
Use auto-layout helpers when several shapes should align or distribute predictably.
|
||||
|
||||
```js
|
||||
const title = slide.shapes.add({ geometry: "rect" });
|
||||
const subtitle = slide.shapes.add({ geometry: "rect" });
|
||||
|
||||
title.position = { width: 720, height: 72 };
|
||||
subtitle.position = { width: 720, height: 40 };
|
||||
|
||||
slide.autoLayout([title, subtitle], {
|
||||
direction: AutoLayoutDirection.vertical,
|
||||
frame: "slide",
|
||||
align: AutoLayoutAlign.topLeft,
|
||||
horizontalPadding: 72,
|
||||
verticalPadding: 64,
|
||||
verticalGap: 12,
|
||||
});
|
||||
```
|
||||
|
||||
Useful enums:
|
||||
|
||||
- `AutoLayoutDirection.vertical`
|
||||
- `AutoLayoutDirection.horizontal`
|
||||
- `AutoLayoutAlign.topLeft`
|
||||
- `AutoLayoutAlign.center`
|
||||
- `AutoLayoutAlign.bottomLeft`
|
||||
|
||||
Prefer auto-layout for title stacks, card grids, and footer or header placement instead of hand-adjusting every `left` and `top`.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Presentation API
|
||||
|
||||
Use `Presentation` as the main facade for authoring and editing decks.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```js
|
||||
const presentation = Presentation.create({
|
||||
slideSize: { width: 960, height: 540 },
|
||||
});
|
||||
```
|
||||
|
||||
- `Presentation.create()` creates a new empty deck.
|
||||
- `await PresentationFile.importPptx(await FileBlob.load("deck.pptx"))` imports an existing deck.
|
||||
- `await PresentationFile.exportPptx(presentation)` exports the deck as a saveable blob.
|
||||
- When using this skill operationally, start by authoring with these APIs rather than checking local runtime package directories first. Runtime or package-cache inspection is a fallback for cases where the `artifacts` tool itself fails before deck code executes.
|
||||
|
||||
## Slides
|
||||
|
||||
- `presentation.slides.add()` appends a slide.
|
||||
- `presentation.slides.insert({ after, layout, layoutId })` inserts relative to another slide.
|
||||
- `presentation.slides.getItem(index)` returns a slide by zero-based index.
|
||||
- `presentation.slides.items` exposes the backing collection when you need to iterate. Do not assume the collection is a normal array or that `.get(...)` exists.
|
||||
- `presentation.setActiveSlide(slide)` and `presentation.getActiveSlide()` manage the active pointer.
|
||||
|
||||
## Content
|
||||
|
||||
- `slide.shapes.add({ geometry, position, fill, line })`
|
||||
- `slide.elements.images.add(...)`
|
||||
- `slide.tables.add(...)`
|
||||
- `slide.elements.charts.add("line", { position })`
|
||||
|
||||
For the current runtime, charts are most reliable when created as slide elements:
|
||||
|
||||
```js
|
||||
const chart = slide.elements.charts.add("line", {
|
||||
position: { left: 80, top: 180, width: 640, height: 320 },
|
||||
});
|
||||
chart.title = "Representative factory horsepower";
|
||||
chart.categories = ["1964", "1973", "1989", "2024"];
|
||||
const series = chart.series.add("911");
|
||||
series.values = [130, 210, 247, 518];
|
||||
chart.hasLegend = false;
|
||||
```
|
||||
|
||||
For preview-safe images, prefer embedded bytes over a bare path or URL:
|
||||
|
||||
```js
|
||||
const fs = await import("node:fs/promises");
|
||||
const source = await fs.readFile("artifacts/porsche.jpg");
|
||||
const imageBuffer = source.buffer.slice(
|
||||
source.byteOffset,
|
||||
source.byteOffset + source.byteLength,
|
||||
);
|
||||
|
||||
slide.elements.images.add({
|
||||
blob: imageBuffer,
|
||||
contentType: "image/jpeg",
|
||||
position: { left: 500, top: 0, width: 460, height: 540 },
|
||||
fit: "cover",
|
||||
});
|
||||
```
|
||||
|
||||
Passing only `path`, `url`, `src`, or a Node `Buffer` may appear to work for export setup but fail during PNG preview rendering. Use an `ArrayBuffer` when the image must render in previews reliably.
|
||||
|
||||
When exporting a fully custom deck, also watch for inherited layout placeholders in the PPTX output. These often show up as empty shapes named `Title 1`, `Subtitle 2`, `Date Placeholder 3`, `Footer Placeholder 4`, or `Slide Number Placeholder 5`, and PowerPoint may render them as `Click to add ...` boxes even if preview PNGs look fine.
|
||||
|
||||
Use a cleanup pass before final export when placeholders are not wanted:
|
||||
|
||||
```js
|
||||
const placeholderNames = new Set([
|
||||
"Title 1",
|
||||
"Subtitle 2",
|
||||
"Date Placeholder 3",
|
||||
"Footer Placeholder 4",
|
||||
"Slide Number Placeholder 5",
|
||||
]);
|
||||
|
||||
for (const slide of presentation.slides.items) {
|
||||
const toDelete = slide.shapes.items.filter((shape) => {
|
||||
const name = shape.name ?? "";
|
||||
return placeholderNames.has(name) || Boolean(shape.placeholderType);
|
||||
});
|
||||
for (const shape of toDelete) {
|
||||
shape.delete();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Collection access is not fully array-like:
|
||||
|
||||
- Use `slide.shapes.getItem(index)` or `slide.shapes.items[index]` for reads.
|
||||
- Prefer probing with `Object.getOwnPropertyNames(Object.getPrototypeOf(...))` if you are unsure what a collection or element exposes.
|
||||
|
||||
## Text
|
||||
|
||||
Basic text works through the `text` property:
|
||||
|
||||
```js
|
||||
const title = slide.shapes.add({
|
||||
geometry: "rect",
|
||||
position: { left: 80, top: 72, width: 800, height: 96 },
|
||||
});
|
||||
title.text = "Q2 Product Update";
|
||||
```
|
||||
|
||||
Simple styling works through `textStyle`:
|
||||
|
||||
```js
|
||||
title.textStyle = {
|
||||
fontSize: 28,
|
||||
color: "#101820",
|
||||
bold: true,
|
||||
italic: false,
|
||||
};
|
||||
```
|
||||
|
||||
Text boxes do not auto-resize to fit content. If the text might wrap, increase the shape height before you add more copy, and leave visible padding on all sides rather than sizing the box flush to the current line count.
|
||||
|
||||
Treat text placement as a layout quality check, not a best-effort outcome:
|
||||
|
||||
- Make sure the text color has strong contrast against the fill, image, or slide background behind it.
|
||||
- Keep text fully inside the intended geometry with visible padding on each side.
|
||||
- Do not allow text boxes to overlap each other or hide beneath charts, images, or decorative shapes.
|
||||
- When text is inside a box or card, choose alignment intentionally. Center only when the design calls for centered copy; otherwise use the alignment that best matches the surrounding layout.
|
||||
|
||||
When you are authoring a new deck, confirm typography and clipping by rendering a PNG preview rather than assuming the slide will look right after export.
|
||||
|
||||
All geometry uses CSS pixels at 96 DPI.
|
||||
|
||||
## Preview
|
||||
|
||||
```js
|
||||
const fs = await import("node:fs/promises");
|
||||
const preview = await presentation.export({
|
||||
slide,
|
||||
format: "png",
|
||||
scale: 2,
|
||||
});
|
||||
const previewBytes = new Uint8Array(await preview.arrayBuffer());
|
||||
await fs.writeFile("artifacts/slide-1.png", previewBytes);
|
||||
```
|
||||
|
||||
- `format` is usually `"png"` or `"jpeg"`.
|
||||
- `scale` increases output resolution.
|
||||
- `presentation.export(...)` returns an image blob. It is not the same as `FileBlob`, so write it via `arrayBuffer()` and Node file I/O.
|
||||
- For QA, rendered PNGs are the fastest trustworthy check for spacing, contrast, clipping, alignment, and unintended text overlap.
|
||||
- Inspect every slide image before handoff. Small cards, labels, and footer text are the common overflow traps, and stacked text boxes can silently overlap after export. If one text block sits on top of another, if text becomes visually superposed, if text color blends into the background, if text alignment inside a box looks accidental, or if any line touches a box edge, resize the container, move the elements apart, change the styling, or shorten the copy and re-render.
|
||||
|
||||
## Output
|
||||
|
||||
Prefer saving artifacts into an `artifacts/` directory in the current working tree so the user can inspect outputs easily.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: spreadsheets
|
||||
description: Build, edit, recalculate, import, and export spreadsheet workbooks with the preloaded @oai/artifact-tool JavaScript surface through the artifacts tool.
|
||||
metadata:
|
||||
short-description: Use the artifacts tool to create and edit spreadsheets in JavaScript
|
||||
---
|
||||
|
||||
# Spreadsheets
|
||||
|
||||
Use this skill when the user wants to create or modify workbooks with the `artifacts` tool.
|
||||
|
||||
## Tool Contract
|
||||
|
||||
- Use the `artifacts` tool.
|
||||
- Send raw JavaScript only. Do not send JSON objects, quoted code, or markdown fences.
|
||||
- This tool runs plain JavaScript in Node, not TypeScript. Do not use type annotations, `type`, `interface`, or `import type`.
|
||||
- Do not write `import { ... } from "@oai/artifact-tool"`. The package surface is already preloaded.
|
||||
- Named exports such as `Workbook`, `SpreadsheetFile`, and `FileBlob` are available directly.
|
||||
- The full module is also available as `artifactTool`, `artifacts`, and `codexArtifacts`.
|
||||
- Save outputs under a user-visible path such as `artifacts/revenue-model.xlsx`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```js
|
||||
const workbook = Workbook.create();
|
||||
const sheet = workbook.worksheets.add("Revenue");
|
||||
|
||||
sheet.getRange("A1:C1").values = [["Month", "Bookings", "ARR"]];
|
||||
sheet.getRange("A2:C4").values = [
|
||||
["Jan", 120000, 1440000],
|
||||
["Feb", 135000, 1620000],
|
||||
["Mar", 142000, 1704000],
|
||||
];
|
||||
|
||||
sheet.getRange("E1").values = [["Quarter ARR"]];
|
||||
sheet.getRange("E2").formulas = [["=SUM(C2:C4)"]];
|
||||
|
||||
workbook.recalculate();
|
||||
|
||||
const xlsxBlob = await SpreadsheetFile.exportXlsx(workbook);
|
||||
await xlsxBlob.save("artifacts/revenue-model.xlsx");
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- Create a workbook with `Workbook.create()`.
|
||||
- Import an existing workbook with `await SpreadsheetFile.importXlsx(await FileBlob.load("book.xlsx"))`.
|
||||
- Add sheets with `workbook.worksheets.add(name)`.
|
||||
- Address cells and ranges with A1 notation via `sheet.getRange("A1:C10")`.
|
||||
- Set `range.values` and `range.formulas`, then call `workbook.recalculate()` before reading computed values.
|
||||
- For charts, prefer creating the chart first, then populating it directly on the returned object. In practice, the reliable pattern is `const chart = sheet.charts.add("line"); chart.setPosition("A10", "H24"); chart.title = "..."; chart.categories = [...]; const series = chart.series.add("Name"); series.values = [...];`. Some other chart-construction styles can produce workbook objects that look valid in memory but export to empty or hidden charts in the final `.xlsx`.
|
||||
- For worksheet images, prefer `sheet.images.add({ blob, contentType, anchor: { from: ..., to: ... } })`. The `blob` payload shape is the reliable path.
|
||||
- Export an `.xlsx` with `await SpreadsheetFile.exportXlsx(workbook)`.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Model the workbook structure first: sheets, headers, and key formulas.
|
||||
- Use formulas instead of copying computed values when the sheet should remain editable.
|
||||
- Recalculate before exporting or reading formula results.
|
||||
- If the workbook includes charts or images, verify layout after export, not just in memory. A sheet-level render pass such as `await workbook.render({ sheet: index, format: "png" })` is a good QA step before handoff.
|
||||
- Check where drawings land on the actual sheet. Merged cells and very tall autofit rows can push visible content far below the fold, so QA should confirm not only that a chart exists, but that it appears in an obvious on-sheet location.
|
||||
- When editing an existing workbook, load it first and preserve unaffected sheets.
|
||||
|
||||
## Reference Map
|
||||
|
||||
- [`references/workbook.md`](./references/workbook.md) for workbook lifecycle and worksheet basics.
|
||||
- [`references/ranges.md`](./references/ranges.md) for A1 addressing, values, formulas, and formatting.
|
||||
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "Spreadsheets"
|
||||
short_description: "Create and edit spreadsheets with the artifacts tool"
|
||||
@@ -0,0 +1,30 @@
|
||||
# Ranges
|
||||
|
||||
Ranges use A1 notation and are the main surface for reading and writing cells.
|
||||
|
||||
```js
|
||||
const sheet = workbook.worksheets.add("Sheet1");
|
||||
|
||||
sheet.getRange("A1:C1").values = [["Month", "Bookings", "ARR"]];
|
||||
sheet.getRange("A2:C4").values = [
|
||||
["Jan", 120000, 1440000],
|
||||
["Feb", 135000, 1620000],
|
||||
["Mar", 142000, 1704000],
|
||||
];
|
||||
|
||||
sheet.getRange("E2").formulas = [["=SUM(C2:C4)"]];
|
||||
workbook.recalculate();
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `range.values` for literal values.
|
||||
- Use `range.formulas` for Excel-style formulas.
|
||||
- Call `workbook.recalculate()` before reading formula-driven `range.values`.
|
||||
- Apply formatting through `range.format` when the sheet needs borders, fills, fonts, or number formats.
|
||||
|
||||
## Addressing
|
||||
|
||||
- Single cell: `"B3"`
|
||||
- Rectangle: `"A1:C10"`
|
||||
- Entire row or column ranges are best used sparingly unless the user explicitly wants them.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Workbook API
|
||||
|
||||
Use `Workbook` to create, edit, recalculate, and export spreadsheet artifacts.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```js
|
||||
const workbook = Workbook.create();
|
||||
const sheet = workbook.worksheets.add("Sheet1");
|
||||
```
|
||||
|
||||
- `Workbook.create()` starts a new workbook.
|
||||
- `await SpreadsheetFile.importXlsx(await FileBlob.load("book.xlsx"))` imports an existing workbook.
|
||||
- `workbook.recalculate()` evaluates formulas.
|
||||
- `await SpreadsheetFile.exportXlsx(workbook)` exports a saveable `.xlsx` blob.
|
||||
|
||||
## Worksheets
|
||||
|
||||
- `workbook.worksheets.add(name)` adds or returns a worksheet.
|
||||
- `workbook.worksheets.getItem(nameOrIndex)` fetches an existing sheet.
|
||||
- `workbook.worksheets.getActiveWorksheet()` returns the active sheet when relevant.
|
||||
|
||||
## Charts And Images
|
||||
|
||||
For charts that must survive `.xlsx` export reliably, prefer mutating the returned chart object directly:
|
||||
|
||||
```js
|
||||
const chart = sheet.charts.add("line");
|
||||
chart.setPosition("A10", "H24");
|
||||
chart.title = "Cash runway";
|
||||
chart.categories = ["Month 0", "Month 1", "Month 2"];
|
||||
|
||||
const series = chart.series.add("Savings");
|
||||
series.values = [6000, 6850, 7700];
|
||||
```
|
||||
|
||||
This authoring path is more reliable than some one-shot chart construction styles, which can produce workbook objects that appear fine in memory but export to empty or misplaced charts.
|
||||
|
||||
For worksheet images, prefer a `blob` payload:
|
||||
|
||||
```js
|
||||
const fs = await import("node:fs/promises");
|
||||
const bytes = await fs.readFile("artifacts/chart-preview.png");
|
||||
const blob = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
|
||||
sheet.images.add({
|
||||
blob,
|
||||
contentType: "image/png",
|
||||
anchor: {
|
||||
from: { row: 10, col: 0 },
|
||||
to: { row: 24, col: 8 },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## QA
|
||||
|
||||
If the workbook includes charts or images, verify the final sheet layout after export, not just before export:
|
||||
|
||||
```js
|
||||
const png = await workbook.render({ sheet: 0, format: "png" });
|
||||
```
|
||||
|
||||
Use rendered previews to confirm both of these points:
|
||||
|
||||
- The chart or image actually appears in the exported workbook.
|
||||
- It lands where a user will see it without scrolling far past the main table.
|
||||
|
||||
Merged cells and tall autofit rows can push drawings far below the fold even when the drawing exists and is correctly attached to the sheet.
|
||||
|
||||
## Output
|
||||
|
||||
Prefer saving generated files into `artifacts/` so the user can inspect the workbook directly.
|
||||
@@ -252,6 +252,7 @@ mod tests {
|
||||
&mut metadata,
|
||||
&RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
trace_id: None,
|
||||
cwd: PathBuf::from("/parent/workspace"),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
@@ -289,6 +290,7 @@ mod tests {
|
||||
&mut metadata,
|
||||
&RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
trace_id: None,
|
||||
cwd: PathBuf::from("/fallback/workspace"),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
|
||||
@@ -1043,7 +1043,7 @@ impl App {
|
||||
thread_label,
|
||||
server_name: ev.server_name.clone(),
|
||||
request_id: ev.id.clone(),
|
||||
message: ev.message.clone(),
|
||||
message: ev.request.message().to_string(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user