Merge branch 'main' into rhan/item-metadata-v1

This commit is contained in:
rhan-oai
2026-03-05 11:45:30 -08:00
committed by GitHub
109 changed files with 8117 additions and 1892 deletions
+3 -1
View File
@@ -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",
+1
View File
@@ -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"
+6
View File
@@ -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 }
@@ -550,6 +550,55 @@
"rejected"
],
"type": "string"
"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.",
@@ -1970,8 +2019,8 @@
"id": {
"$ref": "#/definitions/RequestId"
},
"message": {
"type": "string"
"request": {
"$ref": "#/definitions/ElicitationRequest"
},
"server_name": {
"type": "string"
@@ -1986,7 +2035,7 @@
},
"required": [
"id",
"message",
"request",
"server_name",
"type"
],
@@ -7857,8 +7906,8 @@
"id": {
"$ref": "#/definitions/RequestId"
},
"message": {
"type": "string"
"request": {
"$ref": "#/definitions/ElicitationRequest"
},
"server_name": {
"type": "string"
@@ -7873,7 +7922,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"
}
@@ -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": {
@@ -1780,6 +1780,55 @@
"rejected"
],
"type": "string"
"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#",
@@ -3201,8 +3250,8 @@
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"message": {
"type": "string"
"request": {
"$ref": "#/definitions/ElicitationRequest"
},
"server_name": {
"type": "string"
@@ -3217,7 +3266,7 @@
},
"required": [
"id",
"message",
"request",
"server_name",
"type"
],
@@ -5266,6 +5315,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": {
@@ -6979,6 +7124,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": {
@@ -3286,6 +3286,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": {
@@ -4738,8 +4788,8 @@
"id": {
"$ref": "#/definitions/RequestId"
},
"message": {
"type": "string"
"request": {
"$ref": "#/definitions/ElicitationRequest"
},
"server_name": {
"type": "string"
@@ -4754,7 +4804,7 @@
},
"required": [
"id",
"message",
"request",
"server_name",
"type"
],
@@ -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 { EscalationStatus } from "./EscalationStatus";
@@ -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";
@@ -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, });
@@ -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 {
+195 -3
View File
@@ -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,
@@ -2393,8 +2394,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 +4082,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 +4550,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();
+1
View File
@@ -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",
] }
+14
View File
@@ -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 {
@@ -6493,6 +6493,14 @@ fn collect_resume_override_mismatches(
config_snapshot.model_provider_id
));
}
if let Some(requested_service_tier) = request.service_tier.as_ref()
&& requested_service_tier != &config_snapshot.service_tier
{
mismatch_details.push(format!(
"service_tier requested={requested_service_tier:?} active={:?}",
config_snapshot.service_tier
));
}
if let Some(requested_cwd) = request.cwd.as_deref() {
let requested_cwd_path = std::path::PathBuf::from(requested_cwd);
if requested_cwd_path != config_snapshot.cwd {
@@ -7259,6 +7267,43 @@ mod tests {
validate_dynamic_tools(&tools).expect("valid schema");
}
#[test]
fn collect_resume_override_mismatches_includes_service_tier() {
let request = ThreadResumeParams {
thread_id: "thread-1".to_string(),
history: None,
path: None,
model: None,
model_provider: None,
service_tier: Some(Some(codex_protocol::config_types::ServiceTier::Fast)),
cwd: None,
approval_policy: None,
sandbox: None,
config: None,
base_instructions: None,
developer_instructions: None,
personality: None,
persist_extended_history: false,
};
let config_snapshot = ThreadConfigSnapshot {
model: "gpt-5".to_string(),
model_provider_id: "openai".to_string(),
service_tier: Some(codex_protocol::config_types::ServiceTier::Flex),
approval_policy: codex_protocol::protocol::AskForApproval::OnRequest,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
cwd: PathBuf::from("/tmp"),
ephemeral: false,
reasoning_effort: None,
personality: None,
session_source: SessionSource::Cli,
};
assert_eq!(
collect_resume_override_mismatches(&request, &config_snapshot),
vec!["service_tier requested=Some(Fast) active=Some(Flex)".to_string()]
);
}
#[test]
fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> {
let conversation_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?;
+30 -12
View File
@@ -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");
}
@@ -36,6 +36,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
@@ -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![V2UserInput::Text {
text: "Use [$calendar](app://calendar) 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;
+1
View File
@@ -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
+36
View File
@@ -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.
+25 -152
View File
@@ -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(),
},
},
}
}
}
+8
View File
@@ -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;
-522
View File
@@ -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(),
},
},
}
}
}
+28
View File
@@ -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 },
}
+200
View File
@@ -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(),
}
}
+253
View File
@@ -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,
}
+30
View File
@@ -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;
+521
View File
@@ -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(),
},
},
}
}
@@ -84,6 +84,7 @@ async fn models_client_hits_models_endpoint() {
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
+2
View File
@@ -176,6 +176,8 @@ pub(crate) mod tools {
WebSearch {
#[serde(skip_serializing_if = "Option::is_none")]
external_web_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
search_content_types: Option<Vec<String>>,
},
#[serde(rename = "custom")]
Freeform(FreeformTool),
+45 -5
View File
@@ -128,6 +128,7 @@ use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::Instrument;
use tracing::debug;
use tracing::debug_span;
use tracing::error;
use tracing::field;
use tracing::info;
@@ -3987,8 +3988,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,
@@ -4009,7 +4018,12 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
}
fn submission_dispatch_span(sub: &Submission) -> tracing::Span {
let dispatch_span = info_span!("submission_dispatch", submission.id = sub.id.as_str());
let dispatch_span = match &sub.op {
Op::RealtimeConversationAudio(_) => {
debug_span!("submission_dispatch", submission.id = sub.id.as_str())
}
_ => info_span!("submission_dispatch", submission.id = sub.id.as_str()),
};
if let Some(trace) = sub.trace.as_ref()
&& !set_parent_from_w3c_trace_context(&dispatch_span, trace)
{
@@ -4071,6 +4085,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;
@@ -4205,16 +4220,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 };
@@ -6802,6 +6817,8 @@ mod tests {
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::W3cTraceContext;
use opentelemetry::trace::TraceContextExt;
@@ -8709,6 +8726,29 @@ mod tests {
);
}
#[test]
fn submission_dispatch_span_uses_debug_for_realtime_audio() {
init_test_tracing();
let dispatch_span = submission_dispatch_span(&Submission {
id: "sub-1".into(),
op: Op::RealtimeConversationAudio(ConversationAudioParams {
frame: RealtimeAudioFrame {
data: "ZmFrZQ==".into(),
sample_rate: 16_000,
num_channels: 1,
samples_per_channel: Some(160),
},
}),
trace: None,
});
assert_eq!(
dispatch_span.metadata().expect("span metadata").level(),
&tracing::Level::DEBUG
);
}
#[tokio::test]
async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
struct TraceCaptureTask {
+10 -4
View File
@@ -231,10 +231,11 @@ pub async fn process_exec_tool_call(
crate::sandboxing::execute_env(exec_req, stdout_stream).await
}
pub(crate) async fn execute_exec_env(
env: ExecRequest,
pub(crate) async fn execute_exec_request(
exec_request: ExecRequest,
sandbox_policy: &SandboxPolicy,
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> Result<ExecToolCallOutput> {
let ExecRequest {
command,
@@ -248,7 +249,7 @@ pub(crate) async fn execute_exec_env(
sandbox_policy: _sandbox_policy_from_env,
justification,
arg0,
} = env;
} = exec_request;
let params = ExecParams {
command,
@@ -263,7 +264,7 @@ pub(crate) async fn execute_exec_env(
};
let start = Instant::now();
let raw_output_result = exec(params, sandbox, sandbox_policy, stdout_stream).await;
let raw_output_result = exec(params, sandbox, sandbox_policy, stdout_stream, after_spawn).await;
let duration = start.elapsed();
finalize_exec_result(raw_output_result, sandbox, duration)
}
@@ -693,6 +694,7 @@ async fn exec(
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> Result<RawExecToolCallOutput> {
#[cfg(target_os = "windows")]
if sandbox == SandboxType::WindowsRestrictedToken
@@ -738,6 +740,9 @@ async fn exec(
env,
})
.await?;
if let Some(after_spawn) = after_spawn {
after_spawn();
}
consume_truncated_output(child, expiration, stdout_stream).await
}
@@ -1136,6 +1141,7 @@ mod tests {
SandboxType::None,
&SandboxPolicy::new_read_only_policy(),
None,
None,
)
.await?;
assert!(output.timed_out);
+23 -10
View File
@@ -27,6 +27,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;
@@ -294,6 +295,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 +334,7 @@ impl ElicitationRequestManager {
ProtocolRequestId::Integer(value)
}
},
message: match elicitation {
CreateElicitationRequestParams::FormElicitationParams {
message,
..
}
| CreateElicitationRequestParams::UrlElicitationParams {
message,
..
} => message,
},
request,
}),
})
.await;
@@ -6,6 +6,7 @@ use codex_protocol::openai_models::ModelMessages;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::TruncationMode;
use codex_protocol::openai_models::TruncationPolicyConfig;
use codex_protocol::openai_models::WebSearchToolType;
use codex_protocol::openai_models::default_input_modalities;
use crate::config::Config;
@@ -78,6 +79,7 @@ pub(crate) fn model_info_from_slug(slug: &str) -> ModelInfo {
support_verbosity: false,
default_verbosity: None,
apply_patch_tool_type: None,
web_search_tool_type: WebSearchToolType::Text,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
+5 -5
View File
@@ -64,8 +64,8 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
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("- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\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) {
section.push_str("- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n");
@@ -492,7 +492,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 \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\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 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 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,7 +511,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 \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\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 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 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);
}
@@ -530,7 +530,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 \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\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 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 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);
}
+13 -4
View File
@@ -10,7 +10,7 @@ use crate::exec::ExecExpiration;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::execute_exec_env;
use crate::exec::execute_exec_request;
use crate::landlock::allow_network_for_proxy;
use crate::landlock::create_linux_sandbox_command_args;
use crate::protocol::SandboxPolicy;
@@ -418,11 +418,20 @@ impl SandboxManager {
}
pub async fn execute_env(
env: ExecRequest,
exec_request: ExecRequest,
stdout_stream: Option<StdoutStream>,
) -> crate::error::Result<ExecToolCallOutput> {
let effective_policy = env.sandbox_policy.clone();
execute_exec_env(env, &effective_policy, stdout_stream).await
let effective_policy = exec_request.sandbox_policy.clone();
execute_exec_request(exec_request, &effective_policy, stdout_stream, None).await
}
pub async fn execute_exec_request_with_after_spawn(
exec_request: ExecRequest,
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> crate::error::Result<ExecToolCallOutput> {
let effective_policy = exec_request.sandbox_policy.clone();
execute_exec_request(exec_request, &effective_policy, stdout_stream, after_spawn).await
}
#[cfg(test)]
+2 -2
View File
@@ -14,7 +14,7 @@ use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::StreamOutput;
use crate::exec::execute_exec_env;
use crate::exec::execute_exec_request;
use crate::exec_env::create_env;
use crate::parse_command::parse_command;
use crate::protocol::EventMsg;
@@ -177,7 +177,7 @@ pub(crate) async fn execute_user_shell_command(
tx_event: session.get_tx_event(),
});
let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream)
let exec_result = execute_exec_request(exec_env, &sandbox_policy, stdout_stream, None)
.or_cancel(&cancellation_token)
.await;
+51 -42
View File
@@ -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,
+772 -20
View File
@@ -9,7 +9,9 @@ const { builtinModules, createRequire } = require("node:module");
const { createInterface } = require("node:readline");
const { performance } = require("node:perf_hooks");
const path = require("node:path");
const { URL, URLSearchParams, pathToFileURL } = require("node:url");
const { URL, URLSearchParams, fileURLToPath, pathToFileURL } = require(
"node:url",
);
const { inspect, TextDecoder, TextEncoder } = require("node:util");
const vm = require("node:vm");
@@ -76,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.
@@ -84,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;
@@ -151,6 +159,14 @@ const moduleSearchBases = (() => {
const importResolveConditions = new Set(["node", "import"]);
const requireByBase = new Map();
const linkedFileModules = new Map();
const linkedNativeModules = new Map();
const linkedModuleEvaluations = new Map();
function clearLocalFileModuleCaches() {
linkedFileModules.clear();
linkedModuleEvaluations.clear();
}
function canonicalizePath(value) {
try {
@@ -160,6 +176,28 @@ function canonicalizePath(value) {
}
}
function resolveResultToUrl(resolved) {
if (resolved.kind === "builtin") {
return resolved.specifier;
}
if (resolved.kind === "file") {
return pathToFileURL(resolved.path).href;
}
if (resolved.kind === "package") {
return resolved.specifier;
}
throw new Error(`Unsupported module resolution kind: ${resolved.kind}`);
}
function setImportMeta(meta, mod, isMain = false) {
meta.url = pathToFileURL(mod.identifier).href;
meta.filename = mod.identifier;
meta.dirname = path.dirname(mod.identifier);
meta.main = isMain;
meta.resolve = (specifier) =>
resolveResultToUrl(resolveSpecifier(specifier, mod.identifier));
}
function getRequireForBase(base) {
let req = requireByBase.get(base);
if (!req) {
@@ -185,6 +223,41 @@ function isWithinBaseNodeModules(base, resolvedPath) {
);
}
function isExplicitRelativePathSpecifier(specifier) {
return (
specifier.startsWith("./") ||
specifier.startsWith("../") ||
specifier.startsWith(".\\") ||
specifier.startsWith("..\\")
);
}
function isFileUrlSpecifier(specifier) {
if (typeof specifier !== "string" || !specifier.startsWith("file:")) {
return false;
}
try {
return new URL(specifier).protocol === "file:";
} catch {
return false;
}
}
function isPathSpecifier(specifier) {
if (
typeof specifier !== "string" ||
!specifier ||
specifier.trim() !== specifier
) {
return false;
}
return (
isExplicitRelativePathSpecifier(specifier) ||
path.isAbsolute(specifier) ||
isFileUrlSpecifier(specifier)
);
}
function isBarePackageSpecifier(specifier) {
if (
typeof specifier !== "string" ||
@@ -239,7 +312,61 @@ function resolveBareSpecifier(specifier) {
return null;
}
function resolveSpecifier(specifier) {
function resolvePathSpecifier(specifier, referrerIdentifier = null) {
let candidate;
if (isFileUrlSpecifier(specifier)) {
try {
candidate = fileURLToPath(new URL(specifier));
} catch (err) {
throw new Error(`Failed to resolve module "${specifier}": ${err.message}`);
}
} else {
const baseDir =
referrerIdentifier && path.isAbsolute(referrerIdentifier)
? path.dirname(referrerIdentifier)
: process.cwd();
candidate = path.isAbsolute(specifier)
? specifier
: path.resolve(baseDir, specifier);
}
let resolvedPath;
try {
resolvedPath = fs.realpathSync.native(candidate);
} catch (err) {
if (err?.code === "ENOENT") {
throw new Error(`Module not found: ${specifier}`);
}
throw new Error(`Failed to resolve module "${specifier}": ${err.message}`);
}
let stats;
try {
stats = fs.statSync(resolvedPath);
} catch (err) {
if (err?.code === "ENOENT") {
throw new Error(`Module not found: ${specifier}`);
}
throw new Error(`Failed to inspect module "${specifier}": ${err.message}`);
}
if (!stats.isFile()) {
throw new Error(
`Unsupported import specifier "${specifier}" in js_repl. Directory imports are not supported.`,
);
}
const extension = path.extname(resolvedPath).toLowerCase();
if (extension !== ".js" && extension !== ".mjs") {
throw new Error(
`Unsupported import specifier "${specifier}" in js_repl. Only .js and .mjs files are supported.`,
);
}
return { kind: "file", path: resolvedPath };
}
function resolveSpecifier(specifier, referrerIdentifier = null) {
if (specifier.startsWith("node:") || builtinModuleSet.has(specifier)) {
if (isDeniedBuiltin(specifier)) {
throw new Error(
@@ -249,9 +376,13 @@ function resolveSpecifier(specifier) {
return { kind: "builtin", specifier: toNodeBuiltinSpecifier(specifier) };
}
if (isPathSpecifier(specifier)) {
return resolvePathSpecifier(specifier, referrerIdentifier);
}
if (!isBarePackageSpecifier(specifier)) {
throw new Error(
`Unsupported import specifier "${specifier}" in js_repl. Use a package name like "lodash" or "@scope/pkg".`,
`Unsupported import specifier "${specifier}" in js_repl. Use a package name like "lodash" or "@scope/pkg", or a relative/absolute/file:// .js/.mjs path.`,
);
}
@@ -260,19 +391,98 @@ function resolveSpecifier(specifier) {
throw new Error(`Module not found: ${specifier}`);
}
return { kind: "path", path: resolvedBare };
return { kind: "package", path: resolvedBare, specifier };
}
function importResolved(resolved) {
function importNativeResolved(resolved) {
if (resolved.kind === "builtin") {
return import(resolved.specifier);
}
if (resolved.kind === "path") {
if (resolved.kind === "package") {
return import(pathToFileURL(resolved.path).href);
}
throw new Error(`Unsupported module resolution kind: ${resolved.kind}`);
}
async function loadLinkedNativeModule(resolved) {
const key =
resolved.kind === "builtin"
? `builtin:${resolved.specifier}`
: `package:${resolved.path}`;
let modulePromise = linkedNativeModules.get(key);
if (!modulePromise) {
modulePromise = (async () => {
const namespace = await importNativeResolved(resolved);
const exportNames = Object.getOwnPropertyNames(namespace);
return new SyntheticModule(
exportNames,
function initSyntheticModule() {
for (const name of exportNames) {
this.setExport(name, namespace[name]);
}
},
{ context },
);
})();
linkedNativeModules.set(key, modulePromise);
}
return modulePromise;
}
async function loadLinkedFileModule(modulePath) {
let module = linkedFileModules.get(modulePath);
if (!module) {
const source = fs.readFileSync(modulePath, "utf8");
module = new SourceTextModule(source, {
context,
identifier: modulePath,
initializeImportMeta(meta, mod) {
setImportMeta(meta, mod, false);
},
importModuleDynamically(specifier, referrer) {
return importResolved(resolveSpecifier(specifier, referrer?.identifier));
},
});
linkedFileModules.set(modulePath, module);
}
if (module.status === "unlinked") {
await module.link(async (specifier, referencingModule) => {
const resolved = resolveSpecifier(specifier, referencingModule?.identifier);
if (resolved.kind !== "file") {
throw new Error(
`Static import "${specifier}" is not supported from js_repl local files. Use await import("${specifier}") instead.`,
);
}
return loadLinkedFileModule(resolved.path);
});
}
return module;
}
async function loadLinkedModule(resolved) {
if (resolved.kind === "file") {
return loadLinkedFileModule(resolved.path);
}
if (resolved.kind === "builtin" || resolved.kind === "package") {
return loadLinkedNativeModule(resolved);
}
throw new Error(`Unsupported module resolution kind: ${resolved.kind}`);
}
async function importResolved(resolved) {
if (resolved.kind === "file") {
const module = await loadLinkedFileModule(resolved.path);
let evaluation = linkedModuleEvaluations.get(resolved.path);
if (!evaluation) {
evaluation = module.evaluate();
linkedModuleEvaluations.set(resolved.path, evaluation);
}
await evaluation;
return module.namespace;
}
return importNativeResolved(resolved);
}
function collectPatternNames(pattern, kind, map) {
if (!pattern) return;
switch (pattern.type) {
@@ -349,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) {
@@ -374,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) {
@@ -392,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) {
@@ -730,6 +1427,7 @@ function normalizeEmitImageValue(value) {
}
async function handleExec(message) {
clearLocalFileModuleCaches();
activeExecId = message.id;
const pendingBackgroundTasks = new Set();
const tool = (toolName, args) => {
@@ -807,23 +1505,52 @@ 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 };
context.tmpDir = tmpDir;
await withCapturedConsole(context, async (logs) => {
const module = new SourceTextModule(source, {
const cellIdentifier = path.join(
process.cwd(),
`.codex_js_repl_cell_${cellCounter++}.mjs`,
);
module = new SourceTextModule(source, {
context,
identifier: `cell-${cellCounter++}.mjs`,
identifier: cellIdentifier,
initializeImportMeta(meta, mod) {
meta.url = `file://${mod.identifier}`;
setImportMeta(meta, mod, true);
meta.__codexInternalMarkCommittedBindings = markCommittedBindings;
meta.__codexInternalMarkPreludeCompleted = markPreludeCompleted;
},
importModuleDynamically(specifier) {
return importResolved(resolveSpecifier(specifier));
importModuleDynamically(specifier, referrer) {
return importResolved(resolveSpecifier(specifier, referrer?.identifier));
},
});
@@ -846,10 +1573,11 @@ async function handleExec(message) {
);
return synthetic;
}
const resolved = resolveSpecifier(specifier);
return importResolved(resolved);
throw new Error(
`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) {
@@ -861,11 +1589,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,
@@ -874,6 +1603,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,
+608 -8
View File
@@ -2124,7 +2124,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,13 +2137,32 @@ 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(())
}
fn write_js_repl_test_module(
base: &Path,
relative: &str,
contents: &str,
) -> anyhow::Result<()> {
let module_path = base.join(relative);
if let Some(parent) = module_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(module_path, contents)?;
Ok(())
}
#[tokio::test]
async fn js_repl_timeout_does_not_deadlock() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
@@ -3186,7 +3209,338 @@ await codex.emitImage(out);
}
#[tokio::test]
async fn js_repl_rejects_path_specifiers() -> anyhow::Result<()> {
async fn js_repl_supports_relative_file_imports() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let cwd_dir = tempdir()?;
write_js_repl_test_module(
cwd_dir.path(),
"child.js",
"export const value = \"child\";\n",
)?;
write_js_repl_test_module(
cwd_dir.path(),
"parent.js",
"import { value as childValue } from \"./child.js\";\nexport const value = `${childValue}-parent`;\n",
)?;
write_js_repl_test_module(
cwd_dir.path(),
"local.mjs",
"export const value = \"mjs\";\n",
)?;
let (session, mut turn) = make_session_and_context().await;
turn.shell_environment_policy
.r#set
.remove("CODEX_JS_REPL_NODE_MODULE_DIRS");
turn.cwd = cwd_dir.path().to_path_buf();
turn.js_repl = Arc::new(JsReplHandle::with_node_path(
turn.config.js_repl_node_path.clone(),
Vec::new(),
));
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let result = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "const parent = await import(\"./parent.js\"); const other = await import(\"./local.mjs\"); console.log(parent.value); console.log(other.value);".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(result.output.contains("child-parent"));
assert!(result.output.contains("mjs"));
Ok(())
}
#[tokio::test]
async fn js_repl_supports_absolute_file_imports() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let module_dir = tempdir()?;
let cwd_dir = tempdir()?;
write_js_repl_test_module(
module_dir.path(),
"absolute.js",
"export const value = \"absolute\";\n",
)?;
let absolute_path_json =
serde_json::to_string(&module_dir.path().join("absolute.js").display().to_string())?;
let (session, mut turn) = make_session_and_context().await;
turn.shell_environment_policy
.r#set
.remove("CODEX_JS_REPL_NODE_MODULE_DIRS");
turn.cwd = cwd_dir.path().to_path_buf();
turn.js_repl = Arc::new(JsReplHandle::with_node_path(
turn.config.js_repl_node_path.clone(),
Vec::new(),
));
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let result = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: format!(
"const mod = await import({absolute_path_json}); console.log(mod.value);"
),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(result.output.contains("absolute"));
Ok(())
}
#[tokio::test]
async fn js_repl_imported_local_files_can_access_repl_globals() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let cwd_dir = tempdir()?;
write_js_repl_test_module(
cwd_dir.path(),
"globals.js",
"console.log(codex.tmpDir === tmpDir);\nconsole.log(typeof codex.tool);\nconsole.log(\"local-file-console-ok\");\n",
)?;
let (session, mut turn) = make_session_and_context().await;
turn.shell_environment_policy
.r#set
.remove("CODEX_JS_REPL_NODE_MODULE_DIRS");
turn.cwd = cwd_dir.path().to_path_buf();
turn.js_repl = Arc::new(JsReplHandle::with_node_path(
turn.config.js_repl_node_path.clone(),
Vec::new(),
));
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let result = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "await import(\"./globals.js\");".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(result.output.contains("true"));
assert!(result.output.contains("function"));
assert!(result.output.contains("local-file-console-ok"));
Ok(())
}
#[tokio::test]
async fn js_repl_reimports_local_files_after_edit() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let cwd_dir = tempdir()?;
let helper_path = cwd_dir.path().join("helper.js");
fs::write(&helper_path, "export const value = \"v1\";\n")?;
let (session, mut turn) = make_session_and_context().await;
turn.shell_environment_policy
.r#set
.remove("CODEX_JS_REPL_NODE_MODULE_DIRS");
turn.cwd = cwd_dir.path().to_path_buf();
turn.js_repl = Arc::new(JsReplHandle::with_node_path(
turn.config.js_repl_node_path.clone(),
Vec::new(),
));
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let first = manager
.execute(
Arc::clone(&session),
Arc::clone(&turn),
Arc::clone(&tracker),
JsReplArgs {
code: "const { value: firstValue } = await import(\"./helper.js\");\nconsole.log(firstValue);".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(first.output.contains("v1"));
fs::write(&helper_path, "export const value = \"v2\";\n")?;
let second = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "console.log(firstValue);\nconst { value: secondValue } = await import(\"./helper.js\");\nconsole.log(secondValue);".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(second.output.contains("v1"));
assert!(second.output.contains("v2"));
Ok(())
}
#[tokio::test]
async fn js_repl_reimports_local_files_after_fixing_failure() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let cwd_dir = tempdir()?;
let helper_path = cwd_dir.path().join("broken.js");
fs::write(&helper_path, "throw new Error(\"boom\");\n")?;
let (session, mut turn) = make_session_and_context().await;
turn.shell_environment_policy
.r#set
.remove("CODEX_JS_REPL_NODE_MODULE_DIRS");
turn.cwd = cwd_dir.path().to_path_buf();
turn.js_repl = Arc::new(JsReplHandle::with_node_path(
turn.config.js_repl_node_path.clone(),
Vec::new(),
));
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let err = manager
.execute(
Arc::clone(&session),
Arc::clone(&turn),
Arc::clone(&tracker),
JsReplArgs {
code: "await import(\"./broken.js\");".to_string(),
timeout_ms: Some(10_000),
},
)
.await
.expect_err("expected broken module import to fail");
assert!(err.to_string().contains("boom"));
fs::write(&helper_path, "export const value = \"fixed\";\n")?;
let result = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "console.log((await import(\"./broken.js\")).value);".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(result.output.contains("fixed"));
Ok(())
}
#[tokio::test]
async fn js_repl_local_files_expose_node_like_import_meta() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let cwd_dir = tempdir()?;
let pkg_dir = cwd_dir.path().join("node_modules").join("repl_meta_pkg");
fs::create_dir_all(&pkg_dir)?;
fs::write(
pkg_dir.join("package.json"),
"{\n \"name\": \"repl_meta_pkg\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"exports\": {\n \"import\": \"./index.js\"\n }\n}\n",
)?;
fs::write(
pkg_dir.join("index.js"),
"import { sep } from \"node:path\";\nexport const value = `pkg:${typeof sep}`;\n",
)?;
write_js_repl_test_module(
cwd_dir.path(),
"child.js",
"export const value = \"child-export\";\n",
)?;
write_js_repl_test_module(
cwd_dir.path(),
"meta.js",
"console.log(import.meta.url);\nconsole.log(import.meta.filename);\nconsole.log(import.meta.dirname);\nconsole.log(import.meta.main);\nconsole.log(import.meta.resolve(\"./child.js\"));\nconsole.log(import.meta.resolve(\"repl_meta_pkg\"));\nconsole.log(import.meta.resolve(\"node:fs\"));\nconsole.log((await import(import.meta.resolve(\"./child.js\"))).value);\nconsole.log((await import(import.meta.resolve(\"repl_meta_pkg\"))).value);\n",
)?;
let child_path = fs::canonicalize(cwd_dir.path().join("child.js"))?;
let child_url = url::Url::from_file_path(&child_path)
.expect("child path should convert to file URL")
.to_string();
let (session, mut turn) = make_session_and_context().await;
turn.shell_environment_policy
.r#set
.remove("CODEX_JS_REPL_NODE_MODULE_DIRS");
turn.cwd = cwd_dir.path().to_path_buf();
turn.js_repl = Arc::new(JsReplHandle::with_node_path(
turn.config.js_repl_node_path.clone(),
Vec::new(),
));
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let result = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "await import(\"./meta.js\");".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
let cwd_display = cwd_dir.path().display().to_string();
let meta_path_display = cwd_dir.path().join("meta.js").display().to_string();
assert!(result.output.contains("file://"));
assert!(result.output.contains(&meta_path_display));
assert!(result.output.contains(&cwd_display));
assert!(result.output.contains("false"));
assert!(result.output.contains(&child_url));
assert!(result.output.contains("repl_meta_pkg"));
assert!(result.output.contains("node:fs"));
assert!(result.output.contains("child-export"));
assert!(result.output.contains("pkg:string"));
Ok(())
}
#[tokio::test]
async fn js_repl_rejects_top_level_static_imports_with_clear_error() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
@@ -3203,13 +3557,259 @@ await codex.emitImage(out);
turn,
tracker,
JsReplArgs {
code: "await import(\"./local.js\");".to_string(),
code: "import \"./local.js\";".to_string(),
timeout_ms: Some(10_000),
},
)
.await
.expect_err("expected path specifier to be rejected");
assert!(err.to_string().contains("Unsupported import specifier"));
.expect_err("expected top-level static import to be rejected");
assert!(
err.to_string()
.contains("Top-level static import \"./local.js\" is not supported in js_repl")
);
Ok(())
}
#[tokio::test]
async fn js_repl_local_files_reject_static_bare_imports() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let cwd_dir = tempdir()?;
write_js_repl_test_package(cwd_dir.path(), "repl_counter", "pkg")?;
write_js_repl_test_module(
cwd_dir.path(),
"entry.js",
"import { value } from \"repl_counter\";\nconsole.log(value);\n",
)?;
let (session, mut turn) = make_session_and_context().await;
turn.shell_environment_policy
.r#set
.remove("CODEX_JS_REPL_NODE_MODULE_DIRS");
turn.cwd = cwd_dir.path().to_path_buf();
turn.js_repl = Arc::new(JsReplHandle::with_node_path(
turn.config.js_repl_node_path.clone(),
Vec::new(),
));
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let err = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "await import(\"./entry.js\");".to_string(),
timeout_ms: Some(10_000),
},
)
.await
.expect_err("expected static bare import to be rejected");
assert!(
err.to_string().contains(
"Static import \"repl_counter\" is not supported from js_repl local files"
)
);
Ok(())
}
#[tokio::test]
async fn js_repl_rejects_unsupported_file_specifiers() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let cwd_dir = tempdir()?;
write_js_repl_test_module(cwd_dir.path(), "local.ts", "export const value = \"ts\";\n")?;
write_js_repl_test_module(cwd_dir.path(), "local", "export const value = \"noext\";\n")?;
fs::create_dir_all(cwd_dir.path().join("dir"))?;
let (session, mut turn) = make_session_and_context().await;
turn.shell_environment_policy
.r#set
.remove("CODEX_JS_REPL_NODE_MODULE_DIRS");
turn.cwd = cwd_dir.path().to_path_buf();
turn.js_repl = Arc::new(JsReplHandle::with_node_path(
turn.config.js_repl_node_path.clone(),
Vec::new(),
));
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let unsupported_extension = manager
.execute(
Arc::clone(&session),
Arc::clone(&turn),
Arc::clone(&tracker),
JsReplArgs {
code: "await import(\"./local.ts\");".to_string(),
timeout_ms: Some(10_000),
},
)
.await
.expect_err("expected unsupported extension to be rejected");
assert!(
unsupported_extension
.to_string()
.contains("Only .js and .mjs files are supported")
);
let extensionless = manager
.execute(
Arc::clone(&session),
Arc::clone(&turn),
Arc::clone(&tracker),
JsReplArgs {
code: "await import(\"./local\");".to_string(),
timeout_ms: Some(10_000),
},
)
.await
.expect_err("expected extensionless import to be rejected");
assert!(
extensionless
.to_string()
.contains("Only .js and .mjs files are supported")
);
let directory = manager
.execute(
Arc::clone(&session),
Arc::clone(&turn),
Arc::clone(&tracker),
JsReplArgs {
code: "await import(\"./dir\");".to_string(),
timeout_ms: Some(10_000),
},
)
.await
.expect_err("expected directory import to be rejected");
assert!(
directory
.to_string()
.contains("Directory imports are not supported")
);
let unsupported_url = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "await import(\"https://example.com/test.js\");".to_string(),
timeout_ms: Some(10_000),
},
)
.await
.expect_err("expected unsupported url import to be rejected");
assert!(
unsupported_url
.to_string()
.contains("Unsupported import specifier")
);
Ok(())
}
#[tokio::test]
async fn js_repl_blocks_sensitive_builtin_imports_from_local_files() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let cwd_dir = tempdir()?;
write_js_repl_test_module(
cwd_dir.path(),
"blocked.js",
"import process from \"node:process\";\nconsole.log(process.pid);\n",
)?;
let (session, mut turn) = make_session_and_context().await;
turn.shell_environment_policy
.r#set
.remove("CODEX_JS_REPL_NODE_MODULE_DIRS");
turn.cwd = cwd_dir.path().to_path_buf();
turn.js_repl = Arc::new(JsReplHandle::with_node_path(
turn.config.js_repl_node_path.clone(),
Vec::new(),
));
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let err = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "await import(\"./blocked.js\");".to_string(),
timeout_ms: Some(10_000),
},
)
.await
.expect_err("expected blocked builtin import to be rejected");
assert!(
err.to_string()
.contains("Importing module \"node:process\" is not allowed in js_repl")
);
Ok(())
}
#[tokio::test]
async fn js_repl_local_files_do_not_escape_node_module_search_roots() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let parent_dir = tempdir()?;
write_js_repl_test_package(parent_dir.path(), "repl_probe", "parent")?;
let cwd_dir = parent_dir.path().join("workspace");
fs::create_dir_all(&cwd_dir)?;
write_js_repl_test_module(
&cwd_dir,
"entry.js",
"const { value } = await import(\"repl_probe\");\nconsole.log(value);\n",
)?;
let (session, mut turn) = make_session_and_context().await;
turn.shell_environment_policy
.r#set
.remove("CODEX_JS_REPL_NODE_MODULE_DIRS");
turn.cwd = cwd_dir.clone();
turn.js_repl = Arc::new(JsReplHandle::with_node_path(
turn.config.js_repl_node_path.clone(),
Vec::new(),
));
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let err = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "await import(\"./entry.js\");".to_string(),
timeout_ms: Some(10_000),
},
)
.await
.expect_err("expected parent node_modules lookup to be rejected");
assert!(err.to_string().contains("repl_probe"));
Ok(())
}
}
+3 -4
View File
@@ -5,7 +5,8 @@ Executes shell requests under the orchestrator: asks for approval when needed,
builds a CommandSpec, and runs it under the current SandboxAttempt.
*/
#[cfg(unix)]
mod unix_escalation;
pub(crate) mod unix_escalation;
pub(crate) mod zsh_fork_backend;
use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec::ExecToolCallOutput;
@@ -80,7 +81,6 @@ pub(crate) enum ShellRuntimeBackend {
#[derive(Default)]
pub struct ShellRuntime {
#[cfg_attr(not(unix), allow(dead_code))]
backend: ShellRuntimeBackend,
}
@@ -215,9 +215,8 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
command
};
#[cfg(unix)]
if self.backend == ShellRuntimeBackend::ShellCommandZshFork {
match unix_escalation::try_run_zsh_fork(req, attempt, ctx, &command).await? {
match zsh_fork_backend::maybe_run_shell_command(req, attempt, ctx, &command).await? {
Some(out) => return Ok(out),
None => {
tracing::warn!(
@@ -6,6 +6,7 @@ use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::is_likely_sandbox_denied;
use crate::features::Feature;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::skills::SkillMetadata;
@@ -36,6 +37,7 @@ use codex_shell_escalation::EscalationDecision;
use codex_shell_escalation::EscalationExecution;
use codex_shell_escalation::EscalationPermissions;
use codex_shell_escalation::EscalationPolicy;
use codex_shell_escalation::EscalationSession;
use codex_shell_escalation::ExecParams;
use codex_shell_escalation::ExecResult;
use codex_shell_escalation::Permissions as EscalatedPermissions;
@@ -51,6 +53,11 @@ use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
pub(crate) struct PreparedUnifiedExecZshFork {
pub(crate) exec_request: ExecRequest,
pub(crate) escalation_session: EscalationSession,
}
pub(super) async fn try_run_zsh_fork(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
@@ -95,7 +102,7 @@ pub(super) async fn try_run_zsh_fork(
justification,
arg0,
} = sandbox_exec_request;
let ParsedShellCommand { script, login } = extract_shell_script(&command)?;
let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?;
let effective_timeout = Duration::from_millis(
req.timeout_ms
.unwrap_or(crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
@@ -172,6 +179,103 @@ pub(super) async fn try_run_zsh_fork(
map_exec_result(attempt.sandbox, exec_result).map(Some)
}
pub(crate) async fn prepare_unified_exec_zsh_fork(
req: &crate::tools::runtimes::unified_exec::UnifiedExecRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
exec_request: ExecRequest,
) -> Result<Option<PreparedUnifiedExecZshFork>, ToolError> {
let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_ref() else {
tracing::warn!("ZshFork backend specified, but shell_zsh_path is not configured.");
return Ok(None);
};
if !ctx.session.features().enabled(Feature::ShellZshFork) {
tracing::warn!("ZshFork backend specified, but ShellZshFork feature is not enabled.");
return Ok(None);
}
if !matches!(ctx.session.user_shell().shell_type, ShellType::Zsh) {
tracing::warn!("ZshFork backend specified, but user shell is not Zsh.");
return Ok(None);
}
let parsed = match extract_shell_script(&exec_request.command) {
Ok(parsed) => parsed,
Err(err) => {
tracing::warn!("ZshFork unified exec fallback: {err:?}");
return Ok(None);
}
};
if parsed.program != shell_zsh_path.to_string_lossy() {
tracing::warn!(
"ZshFork backend specified, but unified exec command targets `{}` instead of `{}`.",
parsed.program,
shell_zsh_path.display(),
);
return Ok(None);
}
let exec_policy = Arc::new(RwLock::new(
ctx.session.services.exec_policy.current().as_ref().clone(),
));
let command_executor = CoreShellCommandExecutor {
command: exec_request.command.clone(),
cwd: exec_request.cwd.clone(),
sandbox_policy: exec_request.sandbox_policy.clone(),
sandbox: exec_request.sandbox,
env: exec_request.env.clone(),
network: exec_request.network.clone(),
windows_sandbox_level: exec_request.windows_sandbox_level,
sandbox_permissions: exec_request.sandbox_permissions,
justification: exec_request.justification.clone(),
arg0: exec_request.arg0.clone(),
sandbox_policy_cwd: ctx.turn.cwd.clone(),
macos_seatbelt_profile_extensions: ctx
.turn
.config
.permissions
.macos_seatbelt_profile_extensions
.clone(),
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
use_linux_sandbox_bwrap: ctx.turn.features.enabled(Feature::UseLinuxSandboxBwrap),
};
let main_execve_wrapper_exe = ctx
.session
.services
.main_execve_wrapper_exe
.clone()
.ok_or_else(|| {
ToolError::Rejected(
"zsh fork feature enabled, but execve wrapper is not configured".to_string(),
)
})?;
let escalation_policy = CoreShellActionProvider {
policy: Arc::clone(&exec_policy),
session: Arc::clone(&ctx.session),
turn: Arc::clone(&ctx.turn),
call_id: ctx.call_id.clone(),
approval_policy: ctx.turn.approval_policy.value(),
sandbox_policy: attempt.policy.clone(),
sandbox_permissions: req.sandbox_permissions,
prompt_permissions: req.additional_permissions.clone(),
stopwatch: Stopwatch::unlimited(),
};
let escalate_server = EscalateServer::new(
shell_zsh_path.clone(),
main_execve_wrapper_exe,
escalation_policy,
);
let escalation_session = escalate_server
.start_session(CancellationToken::new(), Arc::new(command_executor))
.map_err(|err| ToolError::Rejected(err.to_string()))?;
let mut exec_request = exec_request;
exec_request.env.extend(escalation_session.env().clone());
Ok(Some(PreparedUnifiedExecZshFork {
exec_request,
escalation_session,
}))
}
struct CoreShellActionProvider {
policy: Arc<RwLock<Policy>>,
session: Arc<crate::codex::Session>,
@@ -648,17 +752,20 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
&self,
_command: Vec<String>,
_cwd: PathBuf,
env: HashMap<String, String>,
env_overlay: HashMap<String, String>,
cancel_rx: CancellationToken,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> anyhow::Result<ExecResult> {
let mut exec_env = self.env.clone();
// `env_overlay` comes from `EscalationSession::env()`, so merge only the
// wrapper/socket variables into the base shell environment.
for var in ["CODEX_ESCALATE_SOCKET", "EXEC_WRAPPER", "BASH_EXEC_WRAPPER"] {
if let Some(value) = env.get(var) {
if let Some(value) = env_overlay.get(var) {
exec_env.insert(var.to_string(), value.clone());
}
}
let result = crate::sandboxing::execute_env(
let result = crate::sandboxing::execute_exec_request_with_after_spawn(
crate::sandboxing::ExecRequest {
command: self.command.clone(),
cwd: self.cwd.clone(),
@@ -673,6 +780,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
arg0: self.arg0.clone(),
},
None,
after_spawn,
)
.await?;
@@ -809,6 +917,7 @@ impl CoreShellCommandExecutor {
#[derive(Debug, Eq, PartialEq)]
struct ParsedShellCommand {
program: String,
script: String,
login: bool,
}
@@ -817,12 +926,20 @@ fn extract_shell_script(command: &[String]) -> Result<ParsedShellCommand, ToolEr
// Commands reaching zsh-fork can be wrapped by environment/sandbox helpers, so
// we search for the first `-c`/`-lc` triple anywhere in the argv rather
// than assuming it is the first positional form.
if let Some((script, login)) = command.windows(3).find_map(|parts| match parts {
[_, flag, script] if flag == "-c" => Some((script.to_owned(), false)),
[_, flag, script] if flag == "-lc" => Some((script.to_owned(), true)),
if let Some((program, script, login)) = command.windows(3).find_map(|parts| match parts {
[program, flag, script] if flag == "-c" => {
Some((program.to_owned(), script.to_owned(), false))
}
[program, flag, script] if flag == "-lc" => {
Some((program.to_owned(), script.to_owned(), true))
}
_ => None,
}) {
return Ok(ParsedShellCommand { script, login });
return Ok(ParsedShellCommand {
program,
script,
login,
});
}
Err(ToolError::Rejected(
@@ -64,6 +64,7 @@ fn extract_shell_script_preserves_login_flag() {
assert_eq!(
extract_shell_script(&["/bin/zsh".into(), "-lc".into(), "echo hi".into()]).unwrap(),
ParsedShellCommand {
program: "/bin/zsh".to_string(),
script: "echo hi".to_string(),
login: true,
}
@@ -71,6 +72,7 @@ fn extract_shell_script_preserves_login_flag() {
assert_eq!(
extract_shell_script(&["/bin/zsh".into(), "-c".into(), "echo hi".into()]).unwrap(),
ParsedShellCommand {
program: "/bin/zsh".to_string(),
script: "echo hi".to_string(),
login: false,
}
@@ -89,6 +91,7 @@ fn extract_shell_script_supports_wrapped_command_prefixes() {
])
.unwrap(),
ParsedShellCommand {
program: "/bin/zsh".to_string(),
script: "echo hello".to_string(),
login: true,
}
@@ -105,6 +108,7 @@ fn extract_shell_script_supports_wrapped_command_prefixes() {
])
.unwrap(),
ParsedShellCommand {
program: "/bin/zsh".to_string(),
script: "pwd".to_string(),
login: false,
}
@@ -0,0 +1,115 @@
use super::ShellRequest;
use crate::exec::ExecToolCallOutput;
use crate::sandboxing::ExecRequest;
use crate::tools::runtimes::unified_exec::UnifiedExecRequest;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::unified_exec::SpawnLifecycleHandle;
pub(crate) struct PreparedUnifiedExecSpawn {
pub(crate) exec_request: ExecRequest,
pub(crate) spawn_lifecycle: SpawnLifecycleHandle,
}
/// Runs the zsh-fork shell-command backend when this request should be handled
/// by executable-level escalation instead of the default shell runtime.
///
/// Returns `Ok(None)` when the current platform or request shape should fall
/// back to the normal shell-command path.
pub(crate) async fn maybe_run_shell_command(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
command: &[String],
) -> Result<Option<ExecToolCallOutput>, ToolError> {
imp::maybe_run_shell_command(req, attempt, ctx, command).await
}
/// Prepares unified exec to launch through the zsh-fork backend when the
/// request matches a wrapped `zsh -c/-lc` command on a supported platform.
///
/// Returns the transformed `ExecRequest` plus a spawn lifecycle that keeps the
/// escalation server alive for the session and performs post-spawn cleanup.
/// Returns `Ok(None)` when unified exec should use its normal spawn path.
pub(crate) async fn maybe_prepare_unified_exec(
req: &UnifiedExecRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
exec_request: ExecRequest,
) -> Result<Option<PreparedUnifiedExecSpawn>, ToolError> {
imp::maybe_prepare_unified_exec(req, attempt, ctx, exec_request).await
}
#[cfg(unix)]
mod imp {
use super::*;
use crate::tools::runtimes::shell::unix_escalation;
use crate::unified_exec::SpawnLifecycle;
use codex_shell_escalation::EscalationSession;
#[derive(Debug)]
struct ZshForkSpawnLifecycle {
escalation_session: EscalationSession,
}
impl SpawnLifecycle for ZshForkSpawnLifecycle {
fn after_spawn(&mut self) {
self.escalation_session.close_client_socket();
}
}
pub(super) async fn maybe_run_shell_command(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
command: &[String],
) -> Result<Option<ExecToolCallOutput>, ToolError> {
unix_escalation::try_run_zsh_fork(req, attempt, ctx, command).await
}
pub(super) async fn maybe_prepare_unified_exec(
req: &UnifiedExecRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
exec_request: ExecRequest,
) -> Result<Option<PreparedUnifiedExecSpawn>, ToolError> {
let Some(prepared) =
unix_escalation::prepare_unified_exec_zsh_fork(req, attempt, ctx, exec_request).await?
else {
return Ok(None);
};
Ok(Some(PreparedUnifiedExecSpawn {
exec_request: prepared.exec_request,
spawn_lifecycle: Box::new(ZshForkSpawnLifecycle {
escalation_session: prepared.escalation_session,
}),
}))
}
}
#[cfg(not(unix))]
mod imp {
use super::*;
pub(super) async fn maybe_run_shell_command(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
command: &[String],
) -> Result<Option<ExecToolCallOutput>, ToolError> {
let _ = (req, attempt, ctx, command);
Ok(None)
}
pub(super) async fn maybe_prepare_unified_exec(
req: &UnifiedExecRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
exec_request: ExecRequest,
) -> Result<Option<PreparedUnifiedExecSpawn>, ToolError> {
let _ = (req, attempt, ctx, exec_request);
Ok(None)
}
}
@@ -16,6 +16,7 @@ use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_command_spec;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::runtimes::shell::zsh_fork_backend;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
@@ -28,6 +29,8 @@ use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::with_cached_approval;
use crate::tools::spec::UnifiedExecBackendConfig;
use crate::unified_exec::NoopSpawnLifecycle;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
@@ -63,11 +66,12 @@ pub struct UnifiedExecApprovalKey {
pub struct UnifiedExecRuntime<'a> {
manager: &'a UnifiedExecProcessManager,
backend: UnifiedExecBackendConfig,
}
impl<'a> UnifiedExecRuntime<'a> {
pub fn new(manager: &'a UnifiedExecProcessManager) -> Self {
Self { manager }
pub fn new(manager: &'a UnifiedExecProcessManager, backend: UnifiedExecBackendConfig) -> Self {
Self { manager, backend }
}
}
@@ -184,6 +188,47 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
if let Some(network) = req.network.as_ref() {
network.apply_to_env(&mut env);
}
if self.backend == UnifiedExecBackendConfig::ZshFork {
let spec = build_command_spec(
&command,
&req.cwd,
&env,
ExecExpiration::DefaultTimeout,
req.sandbox_permissions,
req.additional_permissions.clone(),
req.justification.clone(),
)
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let exec_env = attempt
.env_for(spec, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
match zsh_fork_backend::maybe_prepare_unified_exec(req, attempt, ctx, exec_env).await? {
Some(prepared) => {
return self
.manager
.open_session_with_exec_env(
&prepared.exec_request,
req.tty,
prepared.spawn_lifecycle,
)
.await
.map_err(|err| match err {
UnifiedExecError::SandboxDenied { output, .. } => {
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
}))
}
other => ToolError::Rejected(other.to_string()),
});
}
None => {
tracing::warn!(
"UnifiedExec ZshFork backend specified, but conditions for using it were not met, falling back to direct execution",
);
}
}
}
let spec = build_command_spec(
&command,
&req.cwd,
@@ -198,7 +243,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
.env_for(spec, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
self.manager
.open_session_with_exec_env(&exec_env, req.tty)
.open_session_with_exec_env(&exec_env, req.tty, Box::new(NoopSpawnLifecycle))
.await
.map_err(|err| match err {
UnifiedExecError::SandboxDenied { output, .. } => {
+85 -15
View File
@@ -25,6 +25,7 @@ use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::WebSearchToolType;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use serde::Deserialize;
@@ -36,19 +37,28 @@ use std::collections::HashMap;
const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str =
include_str!("../../templates/search_tool/tool_description.md");
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ShellCommandBackendConfig {
Classic,
ZshFork,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum UnifiedExecBackendConfig {
Direct,
ZshFork,
}
#[derive(Debug, Clone)]
pub(crate) struct ToolsConfig {
pub shell_type: ConfigShellToolType,
shell_command_backend: ShellCommandBackendConfig,
pub unified_exec_backend: UnifiedExecBackendConfig,
pub allow_login_shell: bool,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: Option<WebSearchMode>,
pub web_search_tool_type: WebSearchToolType,
pub image_gen_tool: bool,
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
pub search_tool: bool,
@@ -88,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);
@@ -99,6 +110,12 @@ impl ToolsConfig {
} else {
ShellCommandBackendConfig::Classic
};
let unified_exec_backend =
if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) {
UnifiedExecBackendConfig::ZshFork
} else {
UnifiedExecBackendConfig::Direct
};
let shell_type = if !features.enabled(Feature::ShellTool) {
ConfigShellToolType::Disabled
@@ -137,9 +154,11 @@ impl ToolsConfig {
Self {
shell_type,
shell_command_backend,
unified_exec_backend,
allow_login_shell: true,
apply_patch_tool_type,
web_search_mode: *web_search_mode,
web_search_tool_type: model_info.web_search_tool_type,
image_gen_tool: include_image_gen_tool,
agent_roles: BTreeMap::new(),
search_tool: include_search_tool,
@@ -1387,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(),
@@ -1877,18 +1896,27 @@ pub(crate) fn build_specs(
builder.register_handler("test_sync_tool", test_sync_handler);
}
match config.web_search_mode {
Some(WebSearchMode::Cached) => {
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(false),
});
}
Some(WebSearchMode::Live) => {
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(true),
});
}
Some(WebSearchMode::Disabled) | None => {}
let external_web_access = match config.web_search_mode {
Some(WebSearchMode::Cached) => Some(false),
Some(WebSearchMode::Live) => Some(true),
Some(WebSearchMode::Disabled) | None => None,
};
if let Some(external_web_access) = external_web_access {
let search_content_types = match config.web_search_tool_type {
WebSearchToolType::Text => None,
WebSearchToolType::TextAndImage => Some(
WEB_SEARCH_CONTENT_TYPES
.into_iter()
.map(str::to_string)
.collect(),
),
};
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(external_web_access),
search_content_types,
});
}
if config.image_gen_tool {
@@ -2172,6 +2200,7 @@ mod tests {
create_apply_patch_freeform_tool(),
ToolSpec::WebSearch {
external_web_access: Some(true),
search_content_types: None,
},
create_view_image_tool(),
] {
@@ -2221,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();
@@ -2438,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,
@@ -2486,6 +2518,7 @@ mod tests {
tool.spec,
ToolSpec::WebSearch {
external_web_access: Some(false),
search_content_types: None,
}
);
}
@@ -2510,6 +2543,38 @@ mod tests {
tool.spec,
ToolSpec::WebSearch {
external_web_access: Some(true),
search_content_types: None,
}
);
}
#[test]
fn web_search_tool_type_text_and_image_sets_search_content_types() {
let config = test_config();
let mut model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
model_info.web_search_tool_type = WebSearchToolType::TextAndImage;
let features = Features::with_defaults();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
let tool = find_tool(&tools, "web_search");
assert_eq!(
tool.spec,
ToolSpec::WebSearch {
external_web_access: Some(true),
search_content_types: Some(
WEB_SEARCH_CONTENT_TYPES
.into_iter()
.map(str::to_string)
.collect()
),
}
);
}
@@ -2770,6 +2835,10 @@ mod tests {
tools_config.shell_command_backend,
ShellCommandBackendConfig::ZshFork
);
assert_eq!(
tools_config.unified_exec_backend,
UnifiedExecBackendConfig::ZshFork
);
}
#[test]
@@ -2797,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(),
+4
View File
@@ -49,6 +49,10 @@ pub(crate) fn set_deterministic_process_ids_for_tests(enabled: bool) {
}
pub(crate) use errors::UnifiedExecError;
pub(crate) use process::NoopSpawnLifecycle;
#[cfg(unix)]
pub(crate) use process::SpawnLifecycle;
pub(crate) use process::SpawnLifecycleHandle;
pub(crate) use process::UnifiedExecProcess;
pub(crate) const MIN_YIELD_TIME_MS: u64 = 250;
+16 -1
View File
@@ -24,6 +24,17 @@ use super::UNIFIED_EXEC_OUTPUT_MAX_TOKENS;
use super::UnifiedExecError;
use super::head_tail_buffer::HeadTailBuffer;
pub(crate) trait SpawnLifecycle: std::fmt::Debug + Send + Sync {
fn after_spawn(&mut self) {}
}
pub(crate) type SpawnLifecycleHandle = Box<dyn SpawnLifecycle>;
#[derive(Debug, Default)]
pub(crate) struct NoopSpawnLifecycle;
impl SpawnLifecycle for NoopSpawnLifecycle {}
pub(crate) type OutputBuffer = Arc<Mutex<HeadTailBuffer>>;
pub(crate) struct OutputHandles {
pub(crate) output_buffer: OutputBuffer,
@@ -44,6 +55,7 @@ pub(crate) struct UnifiedExecProcess {
output_drained: Arc<Notify>,
output_task: JoinHandle<()>,
sandbox_type: SandboxType,
_spawn_lifecycle: SpawnLifecycleHandle,
}
impl UnifiedExecProcess {
@@ -51,6 +63,7 @@ impl UnifiedExecProcess {
process_handle: ExecCommandSession,
initial_output_rx: tokio::sync::broadcast::Receiver<Vec<u8>>,
sandbox_type: SandboxType,
spawn_lifecycle: SpawnLifecycleHandle,
) -> Self {
let output_buffer = Arc::new(Mutex::new(HeadTailBuffer::default()));
let output_notify = Arc::new(Notify::new());
@@ -92,6 +105,7 @@ impl UnifiedExecProcess {
output_drained,
output_task,
sandbox_type,
_spawn_lifecycle: spawn_lifecycle,
}
}
@@ -196,13 +210,14 @@ impl UnifiedExecProcess {
pub(super) async fn from_spawned(
spawned: SpawnedPty,
sandbox_type: SandboxType,
spawn_lifecycle: SpawnLifecycleHandle,
) -> Result<Self, UnifiedExecError> {
let SpawnedPty {
session: process_handle,
output_rx,
mut exit_rx,
} = spawned;
let managed = Self::new(process_handle, output_rx, sandbox_type);
let managed = Self::new(process_handle, output_rx, sandbox_type, spawn_lifecycle);
let exit_ready = matches!(exit_rx.try_recv(), Ok(_) | Err(TryRecvError::Closed));
@@ -49,6 +49,7 @@ use crate::unified_exec::generate_chunk_id;
use crate::unified_exec::head_tail_buffer::HeadTailBuffer;
use crate::unified_exec::process::OutputBuffer;
use crate::unified_exec::process::OutputHandles;
use crate::unified_exec::process::SpawnLifecycleHandle;
use crate::unified_exec::process::UnifiedExecProcess;
use crate::unified_exec::resolve_max_tokens;
@@ -528,6 +529,7 @@ impl UnifiedExecProcessManager {
&self,
env: &ExecRequest,
tty: bool,
mut spawn_lifecycle: SpawnLifecycleHandle,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
let (program, args) = env
.command
@@ -555,7 +557,8 @@ impl UnifiedExecProcessManager {
};
let spawned =
spawn_result.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
UnifiedExecProcess::from_spawned(spawned, env.sandbox).await
spawn_lifecycle.after_spawn();
UnifiedExecProcess::from_spawned(spawned, env.sandbox, spawn_lifecycle).await
}
pub(super) async fn open_session_with_sandbox(
@@ -569,7 +572,8 @@ impl UnifiedExecProcessManager {
Some(context.session.conversation_id),
));
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(self);
let mut runtime =
UnifiedExecRuntime::new(self, context.turn.tools_config.unified_exec_backend);
let exec_approval_requirement = context
.session
.services
+394 -58
View File
@@ -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(())
}
@@ -65,6 +65,7 @@ fn test_model_info(
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
@@ -684,6 +685,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
@@ -342,6 +342,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
+2
View File
@@ -647,6 +647,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
@@ -761,6 +762,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
@@ -301,6 +301,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
@@ -542,6 +543,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
@@ -1007,6 +1009,7 @@ fn test_remote_model_with_policy(
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy,
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
+1
View File
@@ -407,6 +407,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
+1
View File
@@ -1003,6 +1003,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
+1
View File
@@ -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?;
}
-1
View File
@@ -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 }
+64 -82
View File
@@ -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()],
},
],
}
);
}
}
+2 -1
View File
@@ -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 }
+64
View File
@@ -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.
+270
View File
@@ -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))
}
+40
View File
@@ -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),
)
})
}
}
+54
View File
@@ -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),
}
+14 -790
View File
@@ -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;
+464
View File
@@ -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,
})
}
+69
View File
@@ -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)
}
}
+48
View File
@@ -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",
}
}
}
+700
View File
@@ -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}"));
}
+26 -5
View File
@@ -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)]
+14
View File
@@ -181,6 +181,16 @@ pub enum ApplyPatchToolType {
Function,
}
#[derive(
Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema, Default,
)]
#[serde(rename_all = "snake_case")]
pub enum WebSearchToolType {
#[default]
Text,
TextAndImage,
}
/// Server-provided truncation policy metadata for a model.
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
#[serde(rename_all = "snake_case")]
@@ -243,6 +253,8 @@ pub struct ModelInfo {
pub support_verbosity: bool,
pub default_verbosity: Option<Verbosity>,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
#[serde(default)]
pub web_search_tool_type: WebSearchToolType,
pub truncation_policy: TruncationPolicyConfig,
pub supports_parallel_tool_calls: bool,
#[serde(default)]
@@ -515,6 +527,7 @@ mod tests {
support_verbosity: false,
default_verbosity: None,
apply_patch_tool_type: None,
web_search_tool_type: WebSearchToolType::Text,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
@@ -718,6 +731,7 @@ mod tests {
assert_eq!(model.availability_nux, None);
assert!(!model.supports_image_detail_original);
assert_eq!(model.web_search_tool_type, WebSearchToolType::Text);
}
#[test]
+3
View File
@@ -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.
+2
View File
@@ -14,6 +14,8 @@ pub use unix::EscalationPermissions;
#[cfg(unix)]
pub use unix::EscalationPolicy;
#[cfg(unix)]
pub use unix::EscalationSession;
#[cfg(unix)]
pub use unix::ExecParams;
#[cfg(unix)]
pub use unix::ExecResult;
@@ -3,11 +3,14 @@ use std::os::fd::AsRawFd;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use anyhow::Context as _;
use codex_utils_absolute_path::AbsolutePathBuf;
use socket2::Socket;
use tokio::process::Command;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::unix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR;
@@ -32,12 +35,20 @@ use crate::unix::socket::AsyncSocket;
#[async_trait::async_trait]
pub trait ShellCommandExecutor: Send + Sync {
/// Runs the requested shell command and returns the captured result.
///
/// `env_overlay` contains only the wrapper/socket variables exported by
/// `EscalationSession::env()`, not a complete child environment.
/// Implementations should merge it into whatever base environment they use
/// for the shell process. `after_spawn` should be invoked immediately after
/// the shell process has been spawned so the parent copy of the inherited
/// escalation socket can be closed.
async fn run(
&self,
command: Vec<String>,
cwd: PathBuf,
env: HashMap<String, String>,
env_overlay: HashMap<String, String>,
cancel_rx: CancellationToken,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> anyhow::Result<ExecResult>;
/// Prepares an escalated subcommand for execution on the server side.
@@ -82,6 +93,38 @@ pub struct PreparedExec {
pub arg0: Option<String>,
}
#[derive(Debug)]
pub struct EscalationSession {
env: HashMap<String, String>,
task: JoinHandle<anyhow::Result<()>>,
client_socket: Arc<Mutex<Option<Socket>>>,
cancellation_token: CancellationToken,
}
impl EscalationSession {
/// Returns just the environment overlay needed by the execve wrapper.
///
/// Callers should merge this into their own child-process environment
/// rather than treating it as the full environment for the shell.
pub fn env(&self) -> &HashMap<String, String> {
&self.env
}
pub fn close_client_socket(&self) {
if let Ok(mut client_socket) = self.client_socket.lock() {
client_socket.take();
}
}
}
impl Drop for EscalationSession {
fn drop(&mut self) {
self.close_client_socket();
self.cancellation_token.cancel();
self.task.abort();
}
}
pub struct EscalateServer {
bash_path: PathBuf,
execve_wrapper: PathBuf,
@@ -106,29 +149,9 @@ impl EscalateServer {
cancel_rx: CancellationToken,
command_executor: Arc<dyn ShellCommandExecutor>,
) -> anyhow::Result<ExecResult> {
let (escalate_server, escalate_client) = AsyncDatagramSocket::pair()?;
let client_socket = escalate_client.into_inner();
// Only the client endpoint should cross exec into the wrapper process.
client_socket.set_cloexec(false)?;
let escalate_task = tokio::spawn(escalate_task(
escalate_server,
Arc::clone(&self.policy),
Arc::clone(&command_executor),
));
let mut env = std::env::vars().collect::<HashMap<String, String>>();
env.insert(
ESCALATE_SOCKET_ENV_VAR.to_string(),
client_socket.as_raw_fd().to_string(),
);
env.insert(
EXEC_WRAPPER_ENV_VAR.to_string(),
self.execve_wrapper.to_string_lossy().to_string(),
);
env.insert(
LEGACY_BASH_EXEC_WRAPPER_ENV_VAR.to_string(),
self.execve_wrapper.to_string_lossy().to_string(),
);
let session = self.start_session(cancel_rx.clone(), Arc::clone(&command_executor))?;
let env_overlay = session.env().clone();
let client_socket = Arc::clone(&session.client_socket);
let command = vec![
self.bash_path.to_string_lossy().to_string(),
if params.login == Some(false) {
@@ -140,20 +163,80 @@ impl EscalateServer {
];
let workdir = AbsolutePathBuf::try_from(params.workdir)?;
let result = command_executor
.run(command, workdir.to_path_buf(), env, cancel_rx)
.run(
command,
workdir.to_path_buf(),
env_overlay,
cancel_rx,
Some(Box::new(move || {
if let Ok(mut client_socket) = client_socket.lock() {
client_socket.take();
}
})),
)
.await?;
escalate_task.abort();
Ok(result)
}
/// Starts an escalation session and returns the environment overlay a shell
/// needs in order to route intercepted execs through this server.
///
/// This does not spawn the shell itself. Callers own process creation and
/// only use the returned environment plus the session lifetime handle.
pub fn start_session(
&self,
parent_cancellation_token: CancellationToken,
command_executor: Arc<dyn ShellCommandExecutor>,
) -> anyhow::Result<EscalationSession> {
let cancellation_token = CancellationToken::new();
let (escalate_server, escalate_client) = AsyncDatagramSocket::pair()?;
let client_socket = escalate_client.into_inner();
let client_socket_fd = client_socket.as_raw_fd();
// Only the client endpoint should cross exec into the wrapper process.
client_socket.set_cloexec(false)?;
let client_socket = Arc::new(Mutex::new(Some(client_socket)));
let task = tokio::spawn(escalate_task(
escalate_server,
Arc::clone(&self.policy),
Arc::clone(&command_executor),
parent_cancellation_token,
cancellation_token.clone(),
));
let mut env = HashMap::new();
env.insert(
ESCALATE_SOCKET_ENV_VAR.to_string(),
client_socket_fd.to_string(),
);
env.insert(
EXEC_WRAPPER_ENV_VAR.to_string(),
self.execve_wrapper.to_string_lossy().to_string(),
);
env.insert(
LEGACY_BASH_EXEC_WRAPPER_ENV_VAR.to_string(),
self.execve_wrapper.to_string_lossy().to_string(),
);
Ok(EscalationSession {
env,
task,
client_socket,
cancellation_token,
})
}
}
async fn escalate_task(
socket: AsyncDatagramSocket,
policy: Arc<dyn EscalationPolicy>,
command_executor: Arc<dyn ShellCommandExecutor>,
parent_cancellation_token: CancellationToken,
session_cancellation_token: CancellationToken,
) -> anyhow::Result<()> {
loop {
let (_, mut fds) = socket.receive_with_fds().await?;
let (_, mut fds) = tokio::select! {
received = socket.receive_with_fds() => received?,
_ = parent_cancellation_token.cancelled() => return Ok(()),
_ = session_cancellation_token.cancelled() => return Ok(()),
};
if fds.len() != 1 {
tracing::error!("expected 1 fd in datagram handshake, got {}", fds.len());
continue;
@@ -161,9 +244,17 @@ async fn escalate_task(
let stream_socket = AsyncSocket::from_fd(fds.remove(0))?;
let policy = Arc::clone(&policy);
let command_executor = Arc::clone(&command_executor);
let parent_cancellation_token = parent_cancellation_token.clone();
let session_cancellation_token = session_cancellation_token.clone();
tokio::spawn(async move {
if let Err(err) =
handle_escalate_session_with_policy(stream_socket, policy, command_executor).await
if let Err(err) = handle_escalate_session_with_policy(
stream_socket,
policy,
command_executor,
parent_cancellation_token,
session_cancellation_token,
)
.await
{
tracing::error!("escalate session failed: {err:?}");
}
@@ -175,18 +266,27 @@ async fn handle_escalate_session_with_policy(
socket: AsyncSocket,
policy: Arc<dyn EscalationPolicy>,
command_executor: Arc<dyn ShellCommandExecutor>,
parent_cancellation_token: CancellationToken,
session_cancellation_token: CancellationToken,
) -> anyhow::Result<()> {
let EscalateRequest {
file,
argv,
workdir,
env,
} = socket.receive::<EscalateRequest>().await?;
} = tokio::select! {
request = socket.receive::<EscalateRequest>() => request?,
_ = parent_cancellation_token.cancelled() => return Ok(()),
_ = session_cancellation_token.cancelled() => return Ok(()),
};
let program = AbsolutePathBuf::resolve_path_against_base(file, workdir.as_path())?;
let decision = policy
.determine_action(&program, &argv, &workdir)
.await
.context("failed to determine escalation action")?;
let decision = tokio::select! {
decision = policy.determine_action(&program, &argv, &workdir) => {
decision.context("failed to determine escalation action")?
}
_ = parent_cancellation_token.cancelled() => return Ok(()),
_ = session_cancellation_token.cancelled() => return Ok(()),
};
tracing::debug!("decided {decision:?} for {program:?} {argv:?} {workdir:?}");
@@ -204,10 +304,13 @@ async fn handle_escalate_session_with_policy(
action: EscalateAction::Escalate,
})
.await?;
let (msg, fds) = socket
.receive_with_fds::<SuperExecMessage>()
.await
.context("failed to receive SuperExecMessage")?;
let (msg, fds) = tokio::select! {
message = socket.receive_with_fds::<SuperExecMessage>() => {
message.context("failed to receive SuperExecMessage")?
}
_ = parent_cancellation_token.cancelled() => return Ok(()),
_ = session_cancellation_token.cancelled() => return Ok(()),
};
if fds.len() != msg.fds.len() {
return Err(anyhow::anyhow!(
"mismatched number of fds in SuperExecMessage: {} in the message, {} from the control message",
@@ -231,9 +334,11 @@ async fn handle_escalate_session_with_policy(
cwd,
env,
arg0,
} = command_executor
.prepare_escalated_exec(&program, &argv, &workdir, env, execution)
.await?;
} = tokio::select! {
prepared = command_executor.prepare_escalated_exec(&program, &argv, &workdir, env, execution) => prepared?,
_ = parent_cancellation_token.cancelled() => return Ok(()),
_ = session_cancellation_token.cancelled() => return Ok(()),
};
let (program, args) = command
.split_first()
.ok_or_else(|| anyhow::anyhow!("prepared escalated command must not be empty"))?;
@@ -245,7 +350,8 @@ async fn handle_escalate_session_with_policy(
.current_dir(&cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
.stderr(Stdio::null())
.kill_on_drop(true);
unsafe {
command.pre_exec(move || {
for (dst_fd, src_fd) in msg.fds.iter().zip(&fds) {
@@ -255,7 +361,17 @@ async fn handle_escalate_session_with_policy(
});
}
let mut child = command.spawn()?;
let exit_status = child.wait().await?;
let exit_status = tokio::select! {
status = child.wait() => status?,
_ = parent_cancellation_token.cancelled() => {
let _ = child.start_kill();
child.wait().await?
}
_ = session_cancellation_token.cancelled() => {
let _ = child.start_kill();
child.wait().await?
}
};
socket
.send(SuperExecResult {
exit_code: exit_status.code().unwrap_or(127),
@@ -282,7 +398,15 @@ mod tests {
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::os::fd::FromRawFd;
use std::path::PathBuf;
use std::sync::LazyLock;
use tempfile::TempDir;
use tokio::time::Instant;
use tokio::time::sleep;
static ESCALATE_SERVER_TEST_LOCK: LazyLock<tokio::sync::Mutex<()>> =
LazyLock::new(|| tokio::sync::Mutex::new(()));
struct DeterministicEscalationPolicy {
decision: EscalationDecision,
@@ -327,8 +451,9 @@ mod tests {
&self,
_command: Vec<String>,
_cwd: PathBuf,
_env: HashMap<String, String>,
_env_overlay: HashMap<String, String>,
_cancel_rx: CancellationToken,
_after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> anyhow::Result<ExecResult> {
unreachable!("run() is not used by handle_escalate_session_with_policy() tests")
}
@@ -362,8 +487,9 @@ mod tests {
&self,
_command: Vec<String>,
_cwd: PathBuf,
_env: HashMap<String, String>,
_env_overlay: HashMap<String, String>,
_cancel_rx: CancellationToken,
_after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> anyhow::Result<ExecResult> {
unreachable!("run() is not used by handle_escalate_session_with_policy() tests")
}
@@ -391,8 +517,160 @@ mod tests {
}
}
async fn wait_for_pid_file(pid_file: &std::path::Path) -> anyhow::Result<i32> {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(contents) = std::fs::read_to_string(pid_file) {
return Ok(contents.trim().parse()?);
}
if Instant::now() >= deadline {
return Err(anyhow::anyhow!(
"timed out waiting for pid file {}",
pid_file.display()
));
}
sleep(Duration::from_millis(20)).await;
}
}
fn process_exists(pid: i32) -> bool {
let rc = unsafe { libc::kill(pid, 0) };
if rc == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
}
struct AfterSpawnAssertingShellCommandExecutor;
#[async_trait::async_trait]
impl ShellCommandExecutor for AfterSpawnAssertingShellCommandExecutor {
async fn run(
&self,
_command: Vec<String>,
_cwd: PathBuf,
env_overlay: HashMap<String, String>,
_cancel_rx: CancellationToken,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> anyhow::Result<ExecResult> {
let socket_fd = env_overlay
.get(ESCALATE_SOCKET_ENV_VAR)
.expect("session should export shell escalation socket")
.parse::<i32>()?;
assert_ne!(unsafe { libc::fcntl(socket_fd, libc::F_GETFD) }, -1);
after_spawn.expect("one-shot exec should install an after-spawn hook")();
assert_eq!(unsafe { libc::fcntl(socket_fd, libc::F_GETFD) }, -1);
Ok(ExecResult {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
output: String::new(),
duration: Duration::ZERO,
timed_out: false,
})
}
async fn prepare_escalated_exec(
&self,
_program: &AbsolutePathBuf,
_argv: &[String],
_workdir: &AbsolutePathBuf,
_env: HashMap<String, String>,
_execution: EscalationExecution,
) -> anyhow::Result<PreparedExec> {
unreachable!("prepare_escalated_exec() is not used by exec() tests")
}
}
async fn wait_for_process_exit(pid: i32) -> anyhow::Result<()> {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if !process_exists(pid) {
return Ok(());
}
if Instant::now() >= deadline {
return Err(anyhow::anyhow!("timed out waiting for pid {pid} to exit"));
}
sleep(Duration::from_millis(20)).await;
}
}
/// Verifies that `start_session()` returns only the wrapper/socket env
/// overlay and does not need to touch the configured shell or wrapper
/// executable paths.
///
/// The `/bin/bash` and `/tmp/codex-execve-wrapper` values here are
/// intentionally fake sentinels: this test asserts that the paths are
/// copied into the exported environment and that the socket fd stays valid
/// until `close_client_socket()` is called.
#[tokio::test]
async fn start_session_exposes_wrapper_env_overlay() -> anyhow::Result<()> {
let _guard = ESCALATE_SERVER_TEST_LOCK.lock().await;
let execve_wrapper = PathBuf::from("/tmp/codex-execve-wrapper");
let execve_wrapper_str = execve_wrapper.to_string_lossy().to_string();
let server = EscalateServer::new(
PathBuf::from("/bin/bash"),
execve_wrapper.clone(),
DeterministicEscalationPolicy {
decision: EscalationDecision::run(),
},
);
let session = server.start_session(
CancellationToken::new(),
Arc::new(ForwardingShellCommandExecutor),
)?;
let env = session.env();
assert_eq!(env.get(EXEC_WRAPPER_ENV_VAR), Some(&execve_wrapper_str));
assert_eq!(
env.get(LEGACY_BASH_EXEC_WRAPPER_ENV_VAR),
Some(&execve_wrapper_str)
);
let socket_fd = env
.get(ESCALATE_SOCKET_ENV_VAR)
.expect("session should export shell escalation socket");
let socket_fd = socket_fd.parse::<i32>()?;
assert!(socket_fd >= 0);
assert_ne!(unsafe { libc::fcntl(socket_fd, libc::F_GETFD) }, -1);
session.close_client_socket();
assert_eq!(unsafe { libc::fcntl(socket_fd, libc::F_GETFD) }, -1);
Ok(())
}
#[tokio::test]
async fn exec_closes_parent_socket_after_shell_spawn() -> anyhow::Result<()> {
let _guard = ESCALATE_SERVER_TEST_LOCK.lock().await;
let server = EscalateServer::new(
PathBuf::from("/bin/bash"),
PathBuf::from("/tmp/codex-execve-wrapper"),
DeterministicEscalationPolicy {
decision: EscalationDecision::run(),
},
);
let result = server
.exec(
ExecParams {
command: "true".to_string(),
workdir: AbsolutePathBuf::current_dir()?
.to_string_lossy()
.to_string(),
timeout_ms: None,
login: Some(false),
},
CancellationToken::new(),
Arc::new(AfterSpawnAssertingShellCommandExecutor),
)
.await?;
assert_eq!(0, result.exit_code);
Ok(())
}
#[tokio::test]
async fn handle_escalate_session_respects_run_in_sandbox_decision() -> anyhow::Result<()> {
let _guard = ESCALATE_SERVER_TEST_LOCK.lock().await;
let (server, client) = AsyncSocket::pair()?;
let server_task = tokio::spawn(handle_escalate_session_with_policy(
server,
@@ -400,6 +678,8 @@ mod tests {
decision: EscalationDecision::run(),
}),
Arc::new(ForwardingShellCommandExecutor),
CancellationToken::new(),
CancellationToken::new(),
));
let mut env = HashMap::new();
@@ -430,6 +710,7 @@ mod tests {
#[tokio::test]
async fn handle_escalate_session_resolves_relative_file_against_request_workdir()
-> anyhow::Result<()> {
let _guard = ESCALATE_SERVER_TEST_LOCK.lock().await;
let (server, client) = AsyncSocket::pair()?;
let tmp = tempfile::TempDir::new()?;
let workdir = tmp.path().join("workspace");
@@ -443,6 +724,8 @@ mod tests {
expected_workdir: workdir.clone(),
}),
Arc::new(ForwardingShellCommandExecutor),
CancellationToken::new(),
CancellationToken::new(),
));
client
@@ -466,6 +749,7 @@ mod tests {
#[tokio::test]
async fn handle_escalate_session_executes_escalated_command() -> anyhow::Result<()> {
let _guard = ESCALATE_SERVER_TEST_LOCK.lock().await;
let (server, client) = AsyncSocket::pair()?;
let server_task = tokio::spawn(handle_escalate_session_with_policy(
server,
@@ -473,6 +757,8 @@ mod tests {
decision: EscalationDecision::escalate(EscalationExecution::Unsandboxed),
}),
Arc::new(ForwardingShellCommandExecutor),
CancellationToken::new(),
CancellationToken::new(),
));
client
@@ -508,6 +794,7 @@ mod tests {
#[tokio::test]
async fn handle_escalate_session_passes_permissions_to_executor() -> anyhow::Result<()> {
let _guard = ESCALATE_SERVER_TEST_LOCK.lock().await;
let (server, client) = AsyncSocket::pair()?;
let server_task = tokio::spawn(handle_escalate_session_with_policy(
server,
@@ -529,6 +816,8 @@ mod tests {
..Default::default()
}),
}),
CancellationToken::new(),
CancellationToken::new(),
));
client
@@ -557,4 +846,95 @@ mod tests {
server_task.await?
}
#[tokio::test]
async fn dropping_session_aborts_intercept_workers_and_kills_spawned_child()
-> anyhow::Result<()> {
let _guard = ESCALATE_SERVER_TEST_LOCK.lock().await;
let tmp = TempDir::new()?;
let pid_file = tmp.path().join("escalated-child.pid");
let pid_file_display = pid_file.display().to_string();
assert!(
!pid_file_display.contains('\''),
"test temp path should not contain single quotes: {pid_file_display}"
);
let server = EscalateServer::new(
PathBuf::from("/bin/bash"),
PathBuf::from("/tmp/codex-execve-wrapper"),
DeterministicEscalationPolicy {
decision: EscalationDecision::escalate(EscalationExecution::Unsandboxed),
},
);
let session = server.start_session(
CancellationToken::new(),
Arc::new(ForwardingShellCommandExecutor),
)?;
let socket_fd = session
.env()
.get(ESCALATE_SOCKET_ENV_VAR)
.expect("session should export shell escalation socket")
.parse::<i32>()?;
let dup_socket_fd = unsafe { libc::dup(socket_fd) };
assert!(dup_socket_fd >= 0, "expected dup() to succeed");
let handshake_client = unsafe { AsyncDatagramSocket::from_raw_fd(dup_socket_fd) }?;
let (server_stream, client_stream) = AsyncSocket::pair()?;
// Keep one local reference to the server end alive until the worker has
// responded once. Without that guard, macOS can observe EOF on the
// client side before the transferred fd is fully servicing the stream.
let server_stream_guard = server_stream.into_inner();
let dup_server_stream_fd = unsafe { libc::dup(server_stream_guard.as_raw_fd()) };
assert!(
dup_server_stream_fd >= 0,
"expected dup() of server stream to succeed"
);
let server_stream_fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(dup_server_stream_fd) };
handshake_client
.send_with_fds(&[0], &[server_stream_fd])
.await
.context("failed to send handshake datagram")?;
client_stream
.send(EscalateRequest {
file: PathBuf::from("/bin/sh"),
argv: vec![
"sh".to_string(),
"-c".to_string(),
format!("echo $$ > '{pid_file_display}' && exec /bin/sleep 100"),
],
workdir: AbsolutePathBuf::current_dir()?,
env: HashMap::new(),
})
.await
.context("failed to send EscalateRequest")?;
let response = client_stream
.receive::<EscalateResponse>()
.await
.context("failed to receive EscalateResponse")?;
assert_eq!(
EscalateResponse {
action: EscalateAction::Escalate,
},
response
);
drop(server_stream_guard);
client_stream
.send_with_fds(SuperExecMessage { fds: Vec::new() }, &[])
.await
.context("failed to send SuperExecMessage")?;
let pid = wait_for_pid_file(&pid_file).await?;
assert!(
process_exists(pid),
"expected spawned child pid {pid} to exist"
);
drop(session);
wait_for_process_exit(pid).await?;
Ok(())
}
}
@@ -66,6 +66,7 @@ pub use self::escalate_protocol::EscalateAction;
pub use self::escalate_protocol::EscalationDecision;
pub use self::escalate_protocol::EscalationExecution;
pub use self::escalate_server::EscalateServer;
pub use self::escalate_server::EscalationSession;
pub use self::escalate_server::ExecParams;
pub use self::escalate_server::ExecResult;
pub use self::escalate_server::PreparedExec;
@@ -9,7 +9,7 @@ use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug)]
pub struct Stopwatch {
limit: Duration,
limit: Option<Duration>,
inner: Arc<Mutex<StopwatchState>>,
notify: Arc<Notify>,
}
@@ -30,13 +30,27 @@ impl Stopwatch {
active_pauses: 0,
})),
notify: Arc::new(Notify::new()),
limit,
limit: Some(limit),
}
}
pub fn unlimited() -> Self {
Self {
inner: Arc::new(Mutex::new(StopwatchState {
elapsed: Duration::ZERO,
running_since: Some(Instant::now()),
active_pauses: 0,
})),
notify: Arc::new(Notify::new()),
limit: None,
}
}
pub fn cancellation_token(&self) -> CancellationToken {
let limit = self.limit;
let token = CancellationToken::new();
let Some(limit) = self.limit else {
return token;
};
let cancel = token.clone();
let inner = Arc::clone(&self.inner);
let notify = Arc::clone(&self.notify);
@@ -208,4 +222,16 @@ mod tests {
// Now the stopwatch should resume and hit the limit shortly after.
token.cancelled().await;
}
#[tokio::test]
async fn unlimited_stopwatch_never_cancels() {
let stopwatch = Stopwatch::unlimited();
let token = stopwatch.cancellation_token();
assert!(
timeout(Duration::from_millis(30), token.cancelled())
.await
.is_err()
);
}
}
@@ -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.
+5 -1
View File
@@ -806,6 +806,8 @@ impl App {
history_cell::SessionHeaderHistoryCell::new(
self.chat_widget.current_model().to_string(),
self.chat_widget.current_reasoning_effort(),
self.chat_widget
.should_show_fast_status(self.chat_widget.current_service_tier()),
self.config.cwd.clone(),
version,
)
@@ -1041,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,
}
@@ -5178,6 +5180,7 @@ mod tests {
is_first,
None,
None,
false,
)) as Arc<dyn HistoryCell>
};
@@ -5827,6 +5830,7 @@ mod tests {
is_first,
None,
None,
false,
)) as Arc<dyn HistoryCell>
};
@@ -588,7 +588,13 @@ mod tests {
msg: EventMsg::ElicitationRequest(codex_protocol::approvals::ElicitationRequestEvent {
server_name: "server-1".to_string(),
id: request_id.clone(),
message: "Please confirm".to_string(),
request: codex_protocol::approvals::ElicitationRequest::Form {
message: "Please confirm".to_string(),
requested_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
}),
});
@@ -596,6 +602,7 @@ mod tests {
server_name: "server-1".to_string(),
request_id,
decision: codex_protocol::approvals::ElicitationAction::Accept,
content: None,
});
let snapshot = store.snapshot();
@@ -280,6 +280,7 @@ impl ApprovalOverlay {
server_name: server_name.to_string(),
request_id: request_id.clone(),
decision,
content: None,
},
});
}
+15 -44
View File
@@ -21,8 +21,6 @@ use crate::app_event::FeedbackCategory;
use crate::app_event_sender::AppEventSender;
use crate::history_cell;
use crate::render::renderable::Renderable;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
use codex_protocol::protocol::SessionSource;
use super::CancellationEvent;
@@ -342,47 +340,9 @@ impl FeedbackNoteView {
text_height.saturating_add(1).min(9)
}
fn intro_lines(&self, width: u16) -> Vec<Line<'static>> {
fn intro_lines(&self, _width: u16) -> Vec<Line<'static>> {
let (title, _) = feedback_title_and_placeholder(self.category);
let mut lines = vec![Line::from(vec![gutter(), title.bold()])];
if should_show_feedback_connectivity_details(
self.category,
self.snapshot.feedback_diagnostics(),
) {
lines.push(Line::from(vec![gutter()]));
lines.push(Line::from(vec![
gutter(),
"Connectivity diagnostics".bold(),
]));
lines.extend(self.diagnostics_lines(width));
}
lines
}
fn diagnostics_lines(&self, width: u16) -> Vec<Line<'static>> {
let width = usize::from(width.max(1));
let headline_options = RtOptions::new(width)
.initial_indent(Line::from(vec![gutter(), " - ".into()]))
.subsequent_indent(Line::from(vec![gutter(), " ".into()]));
let detail_options = RtOptions::new(width)
.initial_indent(Line::from(vec![gutter(), " - ".dim()]))
.subsequent_indent(Line::from(vec![gutter(), " ".into()]));
let mut lines = Vec::new();
for diagnostic in self.snapshot.feedback_diagnostics().diagnostics() {
lines.extend(word_wrap_lines(
[Line::from(diagnostic.headline.clone())],
headline_options.clone(),
));
for detail in &diagnostic.details {
lines.extend(word_wrap_lines(
[Line::from(detail.clone())],
detail_options.clone(),
));
}
}
lines
vec![Line::from(vec![gutter(), title.bold()])]
}
}
@@ -542,7 +502,7 @@ pub(crate) fn feedback_upload_consent_params(
app_event_tx: AppEventSender,
category: FeedbackCategory,
rollout_path: Option<std::path::PathBuf>,
include_connectivity_diagnostics_attachment: bool,
feedback_diagnostics: &FeedbackDiagnostics,
) -> super::SelectionViewParams {
use super::popup_consts::standard_popup_hint_line;
let yes_action: super::SelectionAction = Box::new({
@@ -579,7 +539,7 @@ pub(crate) fn feedback_upload_consent_params(
{
header_lines.push(Line::from(vec![" • ".into(), name.into()]).into());
}
if include_connectivity_diagnostics_attachment {
if !feedback_diagnostics.is_empty() {
header_lines.push(
Line::from(vec![
" • ".into(),
@@ -588,6 +548,17 @@ pub(crate) fn feedback_upload_consent_params(
.into(),
);
}
if should_show_feedback_connectivity_details(category, feedback_diagnostics) {
header_lines.push(Line::from("").into());
header_lines.push(Line::from("Connectivity diagnostics".bold()).into());
for diagnostic in feedback_diagnostics.diagnostics() {
header_lines
.push(Line::from(vec![" - ".into(), diagnostic.headline.clone().into()]).into());
for detail in &diagnostic.details {
header_lines.push(Line::from(vec![" - ".dim(), detail.clone().into()]).into());
}
}
}
super::SelectionViewParams {
footer_hint: Some(standard_popup_hint_line()),

Some files were not shown because too many files have changed in this diff Show More