Merge branch 'main' into codex/tui-file-links-relative-targets

This commit is contained in:
pash-openai
2026-03-06 19:23:43 -08:00
committed by GitHub
159 changed files with 11196 additions and 1402 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ RUN apt-get update && \
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential curl git ca-certificates \
pkg-config clang musl-tools libssl-dev just && \
pkg-config libcap-dev clang musl-tools libssl-dev just && \
rm -rf /var/lib/apt/lists/*
# Ubuntu 24.04 ships with user 'ubuntu' already created with UID 1000.
+1
View File
@@ -1436,6 +1436,7 @@ dependencies = [
"codex-utils-cargo-bin",
"codex-utils-cli",
"codex-utils-json-to-toml",
"codex-utils-pty",
"core_test_support",
"futures",
"owo-colors",
@@ -148,14 +148,54 @@
"type": "object"
},
"CommandExecParams": {
"description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.",
"properties": {
"command": {
"description": "Command argv vector. Empty arrays are rejected.",
"items": {
"type": "string"
},
"type": "array"
},
"cwd": {
"description": "Optional working directory. Defaults to the server cwd.",
"type": [
"string",
"null"
]
},
"disableOutputCap": {
"description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.",
"type": "boolean"
},
"disableTimeout": {
"description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.",
"type": "boolean"
},
"env": {
"additionalProperties": {
"type": [
"string",
"null"
]
},
"description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.",
"type": [
"object",
"null"
]
},
"outputBytesCap": {
"description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.",
"format": "uint",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"processId": {
"description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.",
"type": [
"string",
"null"
@@ -169,14 +209,39 @@
{
"type": "null"
}
]
],
"description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted."
},
"size": {
"anyOf": [
{
"$ref": "#/definitions/CommandExecTerminalSize"
},
{
"type": "null"
}
],
"description": "Optional initial PTY size in character cells. Only valid when `tty` is true."
},
"streamStdin": {
"description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.",
"type": "boolean"
},
"streamStdoutStderr": {
"description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.",
"type": "boolean"
},
"timeoutMs": {
"description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"tty": {
"description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.",
"type": "boolean"
}
},
"required": [
@@ -184,6 +249,87 @@
],
"type": "object"
},
"CommandExecResizeParams": {
"description": "Resize a running PTY-backed `command/exec` session.",
"properties": {
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
},
"size": {
"allOf": [
{
"$ref": "#/definitions/CommandExecTerminalSize"
}
],
"description": "New PTY size in character cells."
}
},
"required": [
"processId",
"size"
],
"type": "object"
},
"CommandExecTerminalSize": {
"description": "PTY size in character cells for `command/exec` PTY sessions.",
"properties": {
"cols": {
"description": "Terminal width in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"rows": {
"description": "Terminal height in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"cols",
"rows"
],
"type": "object"
},
"CommandExecTerminateParams": {
"description": "Terminate a running `command/exec` session.",
"properties": {
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
}
},
"required": [
"processId"
],
"type": "object"
},
"CommandExecWriteParams": {
"description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.",
"properties": {
"closeStdin": {
"description": "Close stdin after writing `deltaBase64`, if present.",
"type": "boolean"
},
"deltaBase64": {
"description": "Optional base64-encoded stdin bytes to write.",
"type": [
"string",
"null"
]
},
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
}
},
"required": [
"processId"
],
"type": "object"
},
"ConfigBatchWriteParams": {
"properties": {
"edits": {
@@ -969,7 +1115,7 @@
"PluginListParams": {
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -1412,7 +1558,7 @@
"action": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchAction"
"$ref": "#/definitions/ResponsesApiWebSearchAction"
},
{
"type": "null"
@@ -1538,6 +1684,107 @@
}
]
},
"ResponsesApiWebSearchAction": {
"oneOf": [
{
"properties": {
"queries": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"query": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"search"
],
"title": "SearchResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "SearchResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"open_page"
],
"title": "OpenPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "OpenPageResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"pattern": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"find_in_page"
],
"title": "FindInPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "FindInPageResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"other"
],
"title": "OtherResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "OtherResponsesApiWebSearchAction",
"type": "object"
}
]
},
"ReviewDelivery": {
"enum": [
"inline",
@@ -2784,107 +3031,6 @@
}
]
},
"WebSearchAction": {
"oneOf": [
{
"properties": {
"queries": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"query": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"search"
],
"title": "SearchWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "SearchWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"open_page"
],
"title": "OpenPageWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "OpenPageWebSearchAction",
"type": "object"
},
{
"properties": {
"pattern": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"find_in_page"
],
"title": "FindInPageWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "FindInPageWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"other"
],
"title": "OtherWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "OtherWebSearchAction",
"type": "object"
}
]
},
"WindowsSandboxSetupMode": {
"enum": [
"elevated",
@@ -3775,7 +3921,7 @@
"type": "object"
},
{
"description": "Execute a command (argv vector) under the server's sandbox.",
"description": "Execute a standalone command (argv vector) under the server's sandbox.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
@@ -3799,6 +3945,81 @@
"title": "Command/execRequest",
"type": "object"
},
{
"description": "Write stdin bytes to a running `command/exec` session or close stdin.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"command/exec/write"
],
"title": "Command/exec/writeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/CommandExecWriteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Command/exec/writeRequest",
"type": "object"
},
{
"description": "Terminate a running `command/exec` session by client-supplied `processId`.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"command/exec/terminate"
],
"title": "Command/exec/terminateRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/CommandExecTerminateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Command/exec/terminateRequest",
"type": "object"
},
{
"description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"command/exec/resize"
],
"title": "Command/exec/resizeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/CommandExecResizeParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Command/exec/resizeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1414,7 +1414,7 @@
{
"properties": {
"action": {
"$ref": "#/definitions/WebSearchAction"
"$ref": "#/definitions/ResponsesApiWebSearchAction"
},
"call_id": {
"type": "string"
@@ -5072,7 +5072,7 @@
"action": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchAction"
"$ref": "#/definitions/ResponsesApiWebSearchAction"
},
{
"type": "null"
@@ -5198,6 +5198,107 @@
}
]
},
"ResponsesApiWebSearchAction": {
"oneOf": [
{
"properties": {
"queries": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"query": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"search"
],
"title": "SearchResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "SearchResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"open_page"
],
"title": "OpenPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "OpenPageResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"pattern": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"find_in_page"
],
"title": "FindInPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "FindInPageResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"other"
],
"title": "OtherResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "OtherResponsesApiWebSearchAction",
"type": "object"
}
]
},
"Result_of_CallToolResult_or_String": {
"oneOf": [
{
@@ -6095,7 +6196,7 @@
{
"properties": {
"action": {
"$ref": "#/definitions/WebSearchAction"
"$ref": "#/definitions/ResponsesApiWebSearchAction"
},
"id": {
"type": "string"
@@ -6306,107 +6407,6 @@
"type": "object"
}
]
},
"WebSearchAction": {
"oneOf": [
{
"properties": {
"queries": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"query": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"search"
],
"title": "SearchWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "SearchWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"open_page"
],
"title": "OpenPageWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "OpenPageWebSearchAction",
"type": "object"
},
{
"properties": {
"pattern": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"find_in_page"
],
"title": "FindInPageWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "FindInPageWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"other"
],
"title": "OtherWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "OtherWebSearchAction",
"type": "object"
}
]
}
},
"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.",
@@ -7226,7 +7226,7 @@
{
"properties": {
"action": {
"$ref": "#/definitions/WebSearchAction"
"$ref": "#/definitions/ResponsesApiWebSearchAction"
},
"call_id": {
"type": "string"
@@ -670,6 +670,57 @@
}
]
},
"CommandExecOutputDeltaNotification": {
"description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.",
"properties": {
"capReached": {
"description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.",
"type": "boolean"
},
"deltaBase64": {
"description": "Base64-encoded output bytes.",
"type": "string"
},
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
},
"stream": {
"allOf": [
{
"$ref": "#/definitions/CommandExecOutputStream"
}
],
"description": "Output stream for this chunk."
}
},
"required": [
"capReached",
"deltaBase64",
"processId",
"stream"
],
"type": "object"
},
"CommandExecOutputStream": {
"description": "Stream label for `command/exec/outputDelta` notifications.",
"oneOf": [
{
"description": "stdout stream. PTY mode multiplexes terminal output here.",
"enum": [
"stdout"
],
"type": "string"
},
{
"description": "stderr stream.",
"enum": [
"stderr"
],
"type": "string"
}
]
},
"CommandExecutionOutputDeltaNotification": {
"properties": {
"delta": {
@@ -3468,6 +3519,27 @@
"title": "Item/plan/deltaNotification",
"type": "object"
},
{
"description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.",
"properties": {
"method": {
"enum": [
"command/exec/outputDelta"
],
"title": "Command/exec/outputDeltaNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/CommandExecOutputDeltaNotification"
}
},
"required": [
"method",
"params"
],
"title": "Command/exec/outputDeltaNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -1218,7 +1218,7 @@
"type": "object"
},
{
"description": "Execute a command (argv vector) under the server's sandbox.",
"description": "Execute a standalone command (argv vector) under the server's sandbox.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
@@ -1242,6 +1242,81 @@
"title": "Command/execRequest",
"type": "object"
},
{
"description": "Write stdin bytes to a running `command/exec` session or close stdin.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"command/exec/write"
],
"title": "Command/exec/writeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/CommandExecWriteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Command/exec/writeRequest",
"type": "object"
},
{
"description": "Terminate a running `command/exec` session by client-supplied `processId`.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"command/exec/terminate"
],
"title": "Command/exec/terminateRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/CommandExecTerminateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Command/exec/terminateRequest",
"type": "object"
},
{
"description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"command/exec/resize"
],
"title": "Command/exec/resizeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/CommandExecResizeParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Command/exec/resizeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -2655,7 +2730,7 @@
{
"properties": {
"action": {
"$ref": "#/definitions/v2/WebSearchAction"
"$ref": "#/definitions/v2/ResponsesApiWebSearchAction"
},
"call_id": {
"type": "string"
@@ -7081,6 +7156,27 @@
"title": "Item/plan/deltaNotification",
"type": "object"
},
{
"description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.",
"properties": {
"method": {
"enum": [
"command/exec/outputDelta"
],
"title": "Command/exec/outputDeltaNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/CommandExecOutputDeltaNotification"
}
},
"required": [
"method",
"params"
],
"title": "Command/exec/outputDeltaNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -8140,7 +8236,7 @@
{
"properties": {
"action": {
"$ref": "#/definitions/v2/WebSearchAction"
"$ref": "#/definitions/v2/ResponsesApiWebSearchAction"
},
"id": {
"type": "string"
@@ -9319,16 +9415,109 @@
}
]
},
"CommandExecOutputDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.",
"properties": {
"capReached": {
"description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.",
"type": "boolean"
},
"deltaBase64": {
"description": "Base64-encoded output bytes.",
"type": "string"
},
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
},
"stream": {
"allOf": [
{
"$ref": "#/definitions/v2/CommandExecOutputStream"
}
],
"description": "Output stream for this chunk."
}
},
"required": [
"capReached",
"deltaBase64",
"processId",
"stream"
],
"title": "CommandExecOutputDeltaNotification",
"type": "object"
},
"CommandExecOutputStream": {
"description": "Stream label for `command/exec/outputDelta` notifications.",
"oneOf": [
{
"description": "stdout stream. PTY mode multiplexes terminal output here.",
"enum": [
"stdout"
],
"type": "string"
},
{
"description": "stderr stream.",
"enum": [
"stderr"
],
"type": "string"
}
]
},
"CommandExecParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.",
"properties": {
"command": {
"description": "Command argv vector. Empty arrays are rejected.",
"items": {
"type": "string"
},
"type": "array"
},
"cwd": {
"description": "Optional working directory. Defaults to the server cwd.",
"type": [
"string",
"null"
]
},
"disableOutputCap": {
"description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.",
"type": "boolean"
},
"disableTimeout": {
"description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.",
"type": "boolean"
},
"env": {
"additionalProperties": {
"type": [
"string",
"null"
]
},
"description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.",
"type": [
"object",
"null"
]
},
"outputBytesCap": {
"description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.",
"format": "uint",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"processId": {
"description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.",
"type": [
"string",
"null"
@@ -9342,14 +9531,39 @@
{
"type": "null"
}
]
],
"description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted."
},
"size": {
"anyOf": [
{
"$ref": "#/definitions/v2/CommandExecTerminalSize"
},
{
"type": "null"
}
],
"description": "Optional initial PTY size in character cells. Only valid when `tty` is true."
},
"streamStdin": {
"description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.",
"type": "boolean"
},
"streamStdoutStderr": {
"description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.",
"type": "boolean"
},
"timeoutMs": {
"description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"tty": {
"description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.",
"type": "boolean"
}
},
"required": [
@@ -9358,17 +9572,51 @@
"title": "CommandExecParams",
"type": "object"
},
"CommandExecResizeParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Resize a running PTY-backed `command/exec` session.",
"properties": {
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
},
"size": {
"allOf": [
{
"$ref": "#/definitions/v2/CommandExecTerminalSize"
}
],
"description": "New PTY size in character cells."
}
},
"required": [
"processId",
"size"
],
"title": "CommandExecResizeParams",
"type": "object"
},
"CommandExecResizeResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/resize`.",
"title": "CommandExecResizeResponse",
"type": "object"
},
"CommandExecResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Final buffered result for `command/exec`.",
"properties": {
"exitCode": {
"description": "Process exit code.",
"format": "int32",
"type": "integer"
},
"stderr": {
"description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.",
"type": "string"
},
"stdout": {
"description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.",
"type": "string"
}
},
@@ -9380,6 +9628,81 @@
"title": "CommandExecResponse",
"type": "object"
},
"CommandExecTerminalSize": {
"description": "PTY size in character cells for `command/exec` PTY sessions.",
"properties": {
"cols": {
"description": "Terminal width in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"rows": {
"description": "Terminal height in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"cols",
"rows"
],
"type": "object"
},
"CommandExecTerminateParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Terminate a running `command/exec` session.",
"properties": {
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
}
},
"required": [
"processId"
],
"title": "CommandExecTerminateParams",
"type": "object"
},
"CommandExecTerminateResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/terminate`.",
"title": "CommandExecTerminateResponse",
"type": "object"
},
"CommandExecWriteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.",
"properties": {
"closeStdin": {
"description": "Close stdin after writing `deltaBase64`, if present.",
"type": "boolean"
},
"deltaBase64": {
"description": "Optional base64-encoded stdin bytes to write.",
"type": [
"string",
"null"
]
},
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
}
},
"required": [
"processId"
],
"title": "CommandExecWriteParams",
"type": "object"
},
"CommandExecWriteResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/write`.",
"title": "CommandExecWriteResponse",
"type": "object"
},
"CommandExecutionOutputDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -11811,11 +12134,112 @@
"title": "PluginInstallResponse",
"type": "object"
},
"PluginInterface": {
"properties": {
"brandColor": {
"type": [
"string",
"null"
]
},
"capabilities": {
"items": {
"type": "string"
},
"type": "array"
},
"category": {
"type": [
"string",
"null"
]
},
"composerIcon": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"defaultPrompt": {
"type": [
"string",
"null"
]
},
"developerName": {
"type": [
"string",
"null"
]
},
"displayName": {
"type": [
"string",
"null"
]
},
"logo": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"longDescription": {
"type": [
"string",
"null"
]
},
"privacyPolicyUrl": {
"type": [
"string",
"null"
]
},
"screenshots": {
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
},
"shortDescription": {
"type": [
"string",
"null"
]
},
"termsOfServiceUrl": {
"type": [
"string",
"null"
]
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"capabilities",
"screenshots"
],
"type": "object"
},
"PluginListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.",
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
@@ -11850,7 +12274,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"plugins": {
"items": {
@@ -11871,7 +12295,7 @@
{
"properties": {
"path": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -11895,6 +12319,22 @@
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"interface": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginInterface"
},
{
"type": "null"
}
]
},
"name": {
"type": "string"
},
@@ -11904,6 +12344,8 @@
},
"required": [
"enabled",
"id",
"installed",
"name",
"source"
],
@@ -11989,6 +12431,16 @@
}
]
},
"tools": {
"anyOf": [
{
"$ref": "#/definitions/v2/ToolsV2"
},
{
"type": "null"
}
]
},
"web_search": {
"anyOf": [
{
@@ -12739,7 +13191,7 @@
"action": {
"anyOf": [
{
"$ref": "#/definitions/v2/WebSearchAction"
"$ref": "#/definitions/v2/ResponsesApiWebSearchAction"
},
{
"type": "null"
@@ -12865,6 +13317,107 @@
}
]
},
"ResponsesApiWebSearchAction": {
"oneOf": [
{
"properties": {
"queries": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"query": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"search"
],
"title": "SearchResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "SearchResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"open_page"
],
"title": "OpenPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "OpenPageResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"pattern": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"find_in_page"
],
"title": "FindInPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "FindInPageResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"other"
],
"title": "OtherResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "OtherResponsesApiWebSearchAction",
"type": "object"
}
]
},
"ReviewDelivery": {
"enum": [
"inline",
@@ -15729,9 +16282,13 @@
]
},
"web_search": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/v2/WebSearchToolConfig"
},
{
"type": "null"
}
]
}
},
@@ -16266,7 +16823,7 @@
"properties": {
"type": {
"enum": [
"open_page"
"openPage"
],
"title": "OpenPageWebSearchActionType",
"type": "string"
@@ -16294,7 +16851,7 @@
},
"type": {
"enum": [
"find_in_page"
"findInPage"
],
"title": "FindInPageWebSearchActionType",
"type": "string"
@@ -16330,6 +16887,44 @@
}
]
},
"WebSearchContextSize": {
"enum": [
"low",
"medium",
"high"
],
"type": "string"
},
"WebSearchLocation": {
"additionalProperties": false,
"properties": {
"city": {
"type": [
"string",
"null"
]
},
"country": {
"type": [
"string",
"null"
]
},
"region": {
"type": [
"string",
"null"
]
},
"timezone": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"WebSearchMode": {
"enum": [
"disabled",
@@ -16338,6 +16933,41 @@
],
"type": "string"
},
"WebSearchToolConfig": {
"additionalProperties": false,
"properties": {
"allowed_domains": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"context_size": {
"anyOf": [
{
"$ref": "#/definitions/v2/WebSearchContextSize"
},
{
"type": "null"
}
]
},
"location": {
"anyOf": [
{
"$ref": "#/definitions/v2/WebSearchLocation"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"WindowsSandboxSetupCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -1741,7 +1741,7 @@
"type": "object"
},
{
"description": "Execute a command (argv vector) under the server's sandbox.",
"description": "Execute a standalone command (argv vector) under the server's sandbox.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
@@ -1765,6 +1765,81 @@
"title": "Command/execRequest",
"type": "object"
},
{
"description": "Write stdin bytes to a running `command/exec` session or close stdin.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"command/exec/write"
],
"title": "Command/exec/writeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/CommandExecWriteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Command/exec/writeRequest",
"type": "object"
},
{
"description": "Terminate a running `command/exec` session by client-supplied `processId`.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"command/exec/terminate"
],
"title": "Command/exec/terminateRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/CommandExecTerminateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Command/exec/terminateRequest",
"type": "object"
},
{
"description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"command/exec/resize"
],
"title": "Command/exec/resizeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/CommandExecResizeParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Command/exec/resizeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -2359,16 +2434,109 @@
}
]
},
"CommandExecOutputDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.",
"properties": {
"capReached": {
"description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.",
"type": "boolean"
},
"deltaBase64": {
"description": "Base64-encoded output bytes.",
"type": "string"
},
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
},
"stream": {
"allOf": [
{
"$ref": "#/definitions/CommandExecOutputStream"
}
],
"description": "Output stream for this chunk."
}
},
"required": [
"capReached",
"deltaBase64",
"processId",
"stream"
],
"title": "CommandExecOutputDeltaNotification",
"type": "object"
},
"CommandExecOutputStream": {
"description": "Stream label for `command/exec/outputDelta` notifications.",
"oneOf": [
{
"description": "stdout stream. PTY mode multiplexes terminal output here.",
"enum": [
"stdout"
],
"type": "string"
},
{
"description": "stderr stream.",
"enum": [
"stderr"
],
"type": "string"
}
]
},
"CommandExecParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.",
"properties": {
"command": {
"description": "Command argv vector. Empty arrays are rejected.",
"items": {
"type": "string"
},
"type": "array"
},
"cwd": {
"description": "Optional working directory. Defaults to the server cwd.",
"type": [
"string",
"null"
]
},
"disableOutputCap": {
"description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.",
"type": "boolean"
},
"disableTimeout": {
"description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.",
"type": "boolean"
},
"env": {
"additionalProperties": {
"type": [
"string",
"null"
]
},
"description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.",
"type": [
"object",
"null"
]
},
"outputBytesCap": {
"description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.",
"format": "uint",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"processId": {
"description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.",
"type": [
"string",
"null"
@@ -2382,14 +2550,39 @@
{
"type": "null"
}
]
],
"description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted."
},
"size": {
"anyOf": [
{
"$ref": "#/definitions/CommandExecTerminalSize"
},
{
"type": "null"
}
],
"description": "Optional initial PTY size in character cells. Only valid when `tty` is true."
},
"streamStdin": {
"description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.",
"type": "boolean"
},
"streamStdoutStderr": {
"description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.",
"type": "boolean"
},
"timeoutMs": {
"description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"tty": {
"description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.",
"type": "boolean"
}
},
"required": [
@@ -2398,17 +2591,51 @@
"title": "CommandExecParams",
"type": "object"
},
"CommandExecResizeParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Resize a running PTY-backed `command/exec` session.",
"properties": {
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
},
"size": {
"allOf": [
{
"$ref": "#/definitions/CommandExecTerminalSize"
}
],
"description": "New PTY size in character cells."
}
},
"required": [
"processId",
"size"
],
"title": "CommandExecResizeParams",
"type": "object"
},
"CommandExecResizeResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/resize`.",
"title": "CommandExecResizeResponse",
"type": "object"
},
"CommandExecResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Final buffered result for `command/exec`.",
"properties": {
"exitCode": {
"description": "Process exit code.",
"format": "int32",
"type": "integer"
},
"stderr": {
"description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.",
"type": "string"
},
"stdout": {
"description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.",
"type": "string"
}
},
@@ -2420,6 +2647,81 @@
"title": "CommandExecResponse",
"type": "object"
},
"CommandExecTerminalSize": {
"description": "PTY size in character cells for `command/exec` PTY sessions.",
"properties": {
"cols": {
"description": "Terminal width in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"rows": {
"description": "Terminal height in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"cols",
"rows"
],
"type": "object"
},
"CommandExecTerminateParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Terminate a running `command/exec` session.",
"properties": {
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
}
},
"required": [
"processId"
],
"title": "CommandExecTerminateParams",
"type": "object"
},
"CommandExecTerminateResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/terminate`.",
"title": "CommandExecTerminateResponse",
"type": "object"
},
"CommandExecWriteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.",
"properties": {
"closeStdin": {
"description": "Close stdin after writing `deltaBase64`, if present.",
"type": "boolean"
},
"deltaBase64": {
"description": "Optional base64-encoded stdin bytes to write.",
"type": [
"string",
"null"
]
},
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
}
},
"required": [
"processId"
],
"title": "CommandExecWriteParams",
"type": "object"
},
"CommandExecWriteResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/write`.",
"title": "CommandExecWriteResponse",
"type": "object"
},
"CommandExecutionOutputDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -4241,7 +4543,7 @@
{
"properties": {
"action": {
"$ref": "#/definitions/WebSearchAction"
"$ref": "#/definitions/ResponsesApiWebSearchAction"
},
"call_id": {
"type": "string"
@@ -8468,11 +8770,112 @@
"title": "PluginInstallResponse",
"type": "object"
},
"PluginInterface": {
"properties": {
"brandColor": {
"type": [
"string",
"null"
]
},
"capabilities": {
"items": {
"type": "string"
},
"type": "array"
},
"category": {
"type": [
"string",
"null"
]
},
"composerIcon": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"defaultPrompt": {
"type": [
"string",
"null"
]
},
"developerName": {
"type": [
"string",
"null"
]
},
"displayName": {
"type": [
"string",
"null"
]
},
"logo": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"longDescription": {
"type": [
"string",
"null"
]
},
"privacyPolicyUrl": {
"type": [
"string",
"null"
]
},
"screenshots": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"shortDescription": {
"type": [
"string",
"null"
]
},
"termsOfServiceUrl": {
"type": [
"string",
"null"
]
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"capabilities",
"screenshots"
],
"type": "object"
},
"PluginListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -8507,7 +8910,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"plugins": {
"items": {
@@ -8528,7 +8931,7 @@
{
"properties": {
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -8552,6 +8955,22 @@
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"interface": {
"anyOf": [
{
"$ref": "#/definitions/PluginInterface"
},
{
"type": "null"
}
]
},
"name": {
"type": "string"
},
@@ -8561,6 +8980,8 @@
},
"required": [
"enabled",
"id",
"installed",
"name",
"source"
],
@@ -8646,6 +9067,16 @@
}
]
},
"tools": {
"anyOf": [
{
"$ref": "#/definitions/ToolsV2"
},
{
"type": "null"
}
]
},
"web_search": {
"anyOf": [
{
@@ -9621,7 +10052,7 @@
"action": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchAction"
"$ref": "#/definitions/ResponsesApiWebSearchAction"
},
{
"type": "null"
@@ -9747,6 +10178,107 @@
}
]
},
"ResponsesApiWebSearchAction": {
"oneOf": [
{
"properties": {
"queries": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"query": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"search"
],
"title": "SearchResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "SearchResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"open_page"
],
"title": "OpenPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "OpenPageResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"pattern": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"find_in_page"
],
"title": "FindInPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
"type": [
"string",
"null"
]
}
},
"required": [
"type"
],
"title": "FindInPageResponsesApiWebSearchAction",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"other"
],
"title": "OtherResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "OtherResponsesApiWebSearchAction",
"type": "object"
}
]
},
"Result_of_CallToolResult_or_String": {
"oneOf": [
{
@@ -10591,6 +11123,27 @@
"title": "Item/plan/deltaNotification",
"type": "object"
},
{
"description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.",
"properties": {
"method": {
"enum": [
"command/exec/outputDelta"
],
"title": "Command/exec/outputDeltaNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/CommandExecOutputDeltaNotification"
}
},
"required": [
"method",
"params"
],
"title": "Command/exec/outputDeltaNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -13761,9 +14314,13 @@
]
},
"web_search": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/WebSearchToolConfig"
},
{
"type": "null"
}
]
}
},
@@ -14029,7 +14586,7 @@
{
"properties": {
"action": {
"$ref": "#/definitions/WebSearchAction"
"$ref": "#/definitions/ResponsesApiWebSearchAction"
},
"id": {
"type": "string"
@@ -14522,7 +15079,7 @@
"properties": {
"type": {
"enum": [
"open_page"
"openPage"
],
"title": "OpenPageWebSearchActionType",
"type": "string"
@@ -14550,7 +15107,7 @@
},
"type": {
"enum": [
"find_in_page"
"findInPage"
],
"title": "FindInPageWebSearchActionType",
"type": "string"
@@ -14586,6 +15143,44 @@
}
]
},
"WebSearchContextSize": {
"enum": [
"low",
"medium",
"high"
],
"type": "string"
},
"WebSearchLocation": {
"additionalProperties": false,
"properties": {
"city": {
"type": [
"string",
"null"
]
},
"country": {
"type": [
"string",
"null"
]
},
"region": {
"type": [
"string",
"null"
]
},
"timezone": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"WebSearchMode": {
"enum": [
"disabled",
@@ -14594,6 +15189,41 @@
],
"type": "string"
},
"WebSearchToolConfig": {
"additionalProperties": false,
"properties": {
"allowed_domains": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"context_size": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchContextSize"
},
{
"type": "null"
}
]
},
"location": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchLocation"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"WindowsSandboxSetupCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -0,0 +1,55 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"CommandExecOutputStream": {
"description": "Stream label for `command/exec/outputDelta` notifications.",
"oneOf": [
{
"description": "stdout stream. PTY mode multiplexes terminal output here.",
"enum": [
"stdout"
],
"type": "string"
},
{
"description": "stderr stream.",
"enum": [
"stderr"
],
"type": "string"
}
]
}
},
"description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.",
"properties": {
"capReached": {
"description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.",
"type": "boolean"
},
"deltaBase64": {
"description": "Base64-encoded output bytes.",
"type": "string"
},
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
},
"stream": {
"allOf": [
{
"$ref": "#/definitions/CommandExecOutputStream"
}
],
"description": "Output stream for this chunk."
}
},
"required": [
"capReached",
"deltaBase64",
"processId",
"stream"
],
"title": "CommandExecOutputDeltaNotification",
"type": "object"
}
@@ -5,6 +5,28 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"CommandExecTerminalSize": {
"description": "PTY size in character cells for `command/exec` PTY sessions.",
"properties": {
"cols": {
"description": "Terminal width in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"rows": {
"description": "Terminal height in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"cols",
"rows"
],
"type": "object"
},
"NetworkAccess": {
"enum": [
"restricted",
@@ -179,14 +201,54 @@
]
}
},
"description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.",
"properties": {
"command": {
"description": "Command argv vector. Empty arrays are rejected.",
"items": {
"type": "string"
},
"type": "array"
},
"cwd": {
"description": "Optional working directory. Defaults to the server cwd.",
"type": [
"string",
"null"
]
},
"disableOutputCap": {
"description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.",
"type": "boolean"
},
"disableTimeout": {
"description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.",
"type": "boolean"
},
"env": {
"additionalProperties": {
"type": [
"string",
"null"
]
},
"description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.",
"type": [
"object",
"null"
]
},
"outputBytesCap": {
"description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.",
"format": "uint",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"processId": {
"description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.",
"type": [
"string",
"null"
@@ -200,14 +262,39 @@
{
"type": "null"
}
]
],
"description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted."
},
"size": {
"anyOf": [
{
"$ref": "#/definitions/CommandExecTerminalSize"
},
{
"type": "null"
}
],
"description": "Optional initial PTY size in character cells. Only valid when `tty` is true."
},
"streamStdin": {
"description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.",
"type": "boolean"
},
"streamStdoutStderr": {
"description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.",
"type": "boolean"
},
"timeoutMs": {
"description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"tty": {
"description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.",
"type": "boolean"
}
},
"required": [
@@ -0,0 +1,48 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"CommandExecTerminalSize": {
"description": "PTY size in character cells for `command/exec` PTY sessions.",
"properties": {
"cols": {
"description": "Terminal width in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"rows": {
"description": "Terminal height in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"cols",
"rows"
],
"type": "object"
}
},
"description": "Resize a running PTY-backed `command/exec` session.",
"properties": {
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
},
"size": {
"allOf": [
{
"$ref": "#/definitions/CommandExecTerminalSize"
}
],
"description": "New PTY size in character cells."
}
},
"required": [
"processId",
"size"
],
"title": "CommandExecResizeParams",
"type": "object"
}
@@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/resize`.",
"title": "CommandExecResizeResponse",
"type": "object"
}
@@ -1,14 +1,18 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Final buffered result for `command/exec`.",
"properties": {
"exitCode": {
"description": "Process exit code.",
"format": "int32",
"type": "integer"
},
"stderr": {
"description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.",
"type": "string"
},
"stdout": {
"description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.",
"type": "string"
}
},
@@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Terminate a running `command/exec` session.",
"properties": {
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
}
},
"required": [
"processId"
],
"title": "CommandExecTerminateParams",
"type": "object"
}
@@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/terminate`.",
"title": "CommandExecTerminateResponse",
"type": "object"
}
@@ -0,0 +1,26 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.",
"properties": {
"closeStdin": {
"description": "Close stdin after writing `deltaBase64`, if present.",
"type": "boolean"
},
"deltaBase64": {
"description": "Optional base64-encoded stdin bytes to write.",
"type": [
"string",
"null"
]
},
"processId": {
"description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.",
"type": "string"
}
},
"required": [
"processId"
],
"title": "CommandExecWriteParams",
"type": "object"
}
@@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/write`.",
"title": "CommandExecWriteResponse",
"type": "object"
}
@@ -628,6 +628,16 @@
}
]
},
"tools": {
"anyOf": [
{
"$ref": "#/definitions/ToolsV2"
},
{
"type": "null"
}
]
},
"web_search": {
"anyOf": [
{
@@ -721,9 +731,13 @@
]
},
"web_search": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/WebSearchToolConfig"
},
{
"type": "null"
}
]
}
},
@@ -738,6 +752,44 @@
],
"type": "string"
},
"WebSearchContextSize": {
"enum": [
"low",
"medium",
"high"
],
"type": "string"
},
"WebSearchLocation": {
"additionalProperties": false,
"properties": {
"city": {
"type": [
"string",
"null"
]
},
"country": {
"type": [
"string",
"null"
]
},
"region": {
"type": [
"string",
"null"
]
},
"timezone": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"WebSearchMode": {
"enum": [
"disabled",
@@ -745,6 +797,41 @@
"live"
],
"type": "string"
},
"WebSearchToolConfig": {
"additionalProperties": false,
"properties": {
"allowed_domains": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"context_size": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchContextSize"
},
{
"type": "null"
}
]
},
"location": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchLocation"
},
{
"type": "null"
}
]
}
},
"type": "object"
}
},
"properties": {
@@ -8,7 +8,7 @@
},
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -1,13 +1,118 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
"type": [
"string",
"null"
]
},
"capabilities": {
"items": {
"type": "string"
},
"type": "array"
},
"category": {
"type": [
"string",
"null"
]
},
"composerIcon": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"defaultPrompt": {
"type": [
"string",
"null"
]
},
"developerName": {
"type": [
"string",
"null"
]
},
"displayName": {
"type": [
"string",
"null"
]
},
"logo": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"longDescription": {
"type": [
"string",
"null"
]
},
"privacyPolicyUrl": {
"type": [
"string",
"null"
]
},
"screenshots": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"shortDescription": {
"type": [
"string",
"null"
]
},
"termsOfServiceUrl": {
"type": [
"string",
"null"
]
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"capabilities",
"screenshots"
],
"type": "object"
},
"PluginMarketplaceEntry": {
"properties": {
"name": {
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"plugins": {
"items": {
@@ -28,7 +133,7 @@
{
"properties": {
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -52,6 +157,22 @@
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"interface": {
"anyOf": [
{
"$ref": "#/definitions/PluginInterface"
},
{
"type": "null"
}
]
},
"name": {
"type": "string"
},
@@ -61,6 +182,8 @@
},
"required": [
"enabled",
"id",
"installed",
"name",
"source"
],
@@ -607,7 +607,7 @@
"action": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchAction"
"$ref": "#/definitions/ResponsesApiWebSearchAction"
},
{
"type": "null"
@@ -733,7 +733,7 @@
}
]
},
"WebSearchAction": {
"ResponsesApiWebSearchAction": {
"oneOf": [
{
"properties": {
@@ -756,14 +756,14 @@
"enum": [
"search"
],
"title": "SearchWebSearchActionType",
"title": "SearchResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "SearchWebSearchAction",
"title": "SearchResponsesApiWebSearchAction",
"type": "object"
},
{
@@ -772,7 +772,7 @@
"enum": [
"open_page"
],
"title": "OpenPageWebSearchActionType",
"title": "OpenPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
@@ -785,7 +785,7 @@
"required": [
"type"
],
"title": "OpenPageWebSearchAction",
"title": "OpenPageResponsesApiWebSearchAction",
"type": "object"
},
{
@@ -800,7 +800,7 @@
"enum": [
"find_in_page"
],
"title": "FindInPageWebSearchActionType",
"title": "FindInPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
@@ -813,7 +813,7 @@
"required": [
"type"
],
"title": "FindInPageWebSearchAction",
"title": "FindInPageResponsesApiWebSearchAction",
"type": "object"
},
{
@@ -822,14 +822,14 @@
"enum": [
"other"
],
"title": "OtherWebSearchActionType",
"title": "OtherResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "OtherWebSearchAction",
"title": "OtherResponsesApiWebSearchAction",
"type": "object"
}
]
@@ -657,7 +657,7 @@
"action": {
"anyOf": [
{
"$ref": "#/definitions/WebSearchAction"
"$ref": "#/definitions/ResponsesApiWebSearchAction"
},
{
"type": "null"
@@ -783,22 +783,7 @@
}
]
},
"SandboxMode": {
"enum": [
"read-only",
"workspace-write",
"danger-full-access"
],
"type": "string"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"WebSearchAction": {
"ResponsesApiWebSearchAction": {
"oneOf": [
{
"properties": {
@@ -821,14 +806,14 @@
"enum": [
"search"
],
"title": "SearchWebSearchActionType",
"title": "SearchResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "SearchWebSearchAction",
"title": "SearchResponsesApiWebSearchAction",
"type": "object"
},
{
@@ -837,7 +822,7 @@
"enum": [
"open_page"
],
"title": "OpenPageWebSearchActionType",
"title": "OpenPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
@@ -850,7 +835,7 @@
"required": [
"type"
],
"title": "OpenPageWebSearchAction",
"title": "OpenPageResponsesApiWebSearchAction",
"type": "object"
},
{
@@ -865,7 +850,7 @@
"enum": [
"find_in_page"
],
"title": "FindInPageWebSearchActionType",
"title": "FindInPageResponsesApiWebSearchActionType",
"type": "string"
},
"url": {
@@ -878,7 +863,7 @@
"required": [
"type"
],
"title": "FindInPageWebSearchAction",
"title": "FindInPageResponsesApiWebSearchAction",
"type": "object"
},
{
@@ -887,17 +872,32 @@
"enum": [
"other"
],
"title": "OtherWebSearchActionType",
"title": "OtherResponsesApiWebSearchActionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "OtherWebSearchAction",
"title": "OtherResponsesApiWebSearchAction",
"type": "object"
}
]
},
"SandboxMode": {
"enum": [
"read-only",
"workspace-write",
"danger-full-access"
],
"type": "string"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
}
},
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.",
@@ -10,6 +10,9 @@ import type { RequestId } from "./RequestId";
import type { AppsListParams } from "./v2/AppsListParams";
import type { CancelLoginAccountParams } from "./v2/CancelLoginAccountParams";
import type { CommandExecParams } from "./v2/CommandExecParams";
import type { CommandExecResizeParams } from "./v2/CommandExecResizeParams";
import type { CommandExecTerminateParams } from "./v2/CommandExecTerminateParams";
import type { CommandExecWriteParams } from "./v2/CommandExecWriteParams";
import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams";
import type { ConfigReadParams } from "./v2/ConfigReadParams";
import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams";
@@ -50,4 +53,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta
/**
* Request from the client to the server.
*/
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
@@ -8,6 +8,7 @@ import type { AccountRateLimitsUpdatedNotification } from "./v2/AccountRateLimit
import type { AccountUpdatedNotification } from "./v2/AccountUpdatedNotification";
import type { AgentMessageDeltaNotification } from "./v2/AgentMessageDeltaNotification";
import type { AppListUpdatedNotification } from "./v2/AppListUpdatedNotification";
import type { CommandExecOutputDeltaNotification } from "./v2/CommandExecOutputDeltaNotification";
import type { CommandExecutionOutputDeltaNotification } from "./v2/CommandExecutionOutputDeltaNotification";
import type { ConfigWarningNotification } from "./v2/ConfigWarningNotification";
import type { ContextCompactedNotification } from "./v2/ContextCompactedNotification";
@@ -49,4 +50,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
/**
* Notification sent from the server to the client.
*/
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
@@ -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 WebSearchContextSize = "low" | "medium" | "high";
@@ -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 WebSearchLocation = { country: string | null, region: string | null, city: string | null, timezone: string | null, };
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WebSearchContextSize } from "./WebSearchContextSize";
import type { WebSearchLocation } from "./WebSearchLocation";
export type WebSearchToolConfig = { context_size: WebSearchContextSize | null, allowed_domains: Array<string> | null, location: WebSearchLocation | null, };
@@ -211,7 +211,10 @@ export type { ViewImageToolCallEvent } from "./ViewImageToolCallEvent";
export type { WarningEvent } from "./WarningEvent";
export type { WebSearchAction } from "./WebSearchAction";
export type { WebSearchBeginEvent } from "./WebSearchBeginEvent";
export type { WebSearchContextSize } from "./WebSearchContextSize";
export type { WebSearchEndEvent } from "./WebSearchEndEvent";
export type { WebSearchItem } from "./WebSearchItem";
export type { WebSearchLocation } from "./WebSearchLocation";
export type { WebSearchMode } from "./WebSearchMode";
export type { WebSearchToolConfig } from "./WebSearchToolConfig";
export * as v2 from "./v2";
@@ -0,0 +1,30 @@
// 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 { CommandExecOutputStream } from "./CommandExecOutputStream";
/**
* Base64-encoded output chunk emitted for a streaming `command/exec` request.
*
* These notifications are connection-scoped. If the originating connection
* closes, the server terminates the process.
*/
export type CommandExecOutputDeltaNotification = {
/**
* Client-supplied, connection-scoped `processId` from the original
* `command/exec` request.
*/
processId: string,
/**
* Output stream for this chunk.
*/
stream: CommandExecOutputStream,
/**
* Base64-encoded output bytes.
*/
deltaBase64: string,
/**
* `true` on the final streamed chunk for a stream when `outputBytesCap`
* truncated later output on that stream.
*/
capReached: boolean, };
@@ -0,0 +1,8 @@
// 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.
/**
* Stream label for `command/exec/outputDelta` notifications.
*/
export type CommandExecOutputStream = "stdout" | "stderr";
@@ -1,6 +1,97 @@
// 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 { CommandExecTerminalSize } from "./CommandExecTerminalSize";
import type { SandboxPolicy } from "./SandboxPolicy";
export type CommandExecParams = { command: Array<string>, timeoutMs?: number | null, cwd?: string | null, sandboxPolicy?: SandboxPolicy | null, };
/**
* Run a standalone command (argv vector) in the server sandbox without
* creating a thread or turn.
*
* The final `command/exec` response is deferred until the process exits and is
* sent only after all `command/exec/outputDelta` notifications for that
* connection have been emitted.
*/
export type CommandExecParams = {
/**
* Command argv vector. Empty arrays are rejected.
*/
command: Array<string>,
/**
* Optional client-supplied, connection-scoped process id.
*
* Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up
* `command/exec/write`, `command/exec/resize`, and
* `command/exec/terminate` calls. When omitted, buffered execution gets an
* internal id that is not exposed to the client.
*/
processId?: string | null,
/**
* Enable PTY mode.
*
* This implies `streamStdin` and `streamStdoutStderr`.
*/
tty?: boolean,
/**
* Allow follow-up `command/exec/write` requests to write stdin bytes.
*
* Requires a client-supplied `processId`.
*/
streamStdin?: boolean,
/**
* Stream stdout/stderr via `command/exec/outputDelta` notifications.
*
* Streamed bytes are not duplicated into the final response and require a
* client-supplied `processId`.
*/
streamStdoutStderr?: boolean,
/**
* Optional per-stream stdout/stderr capture cap in bytes.
*
* When omitted, the server default applies. Cannot be combined with
* `disableOutputCap`.
*/
outputBytesCap?: number | null,
/**
* Disable stdout/stderr capture truncation for this request.
*
* Cannot be combined with `outputBytesCap`.
*/
disableOutputCap?: boolean,
/**
* Disable the timeout entirely for this request.
*
* Cannot be combined with `timeoutMs`.
*/
disableTimeout?: boolean,
/**
* Optional timeout in milliseconds.
*
* When omitted, the server default applies. Cannot be combined with
* `disableTimeout`.
*/
timeoutMs?: number | null,
/**
* Optional working directory. Defaults to the server cwd.
*/
cwd?: string | null,
/**
* Optional environment overrides merged into the server-computed
* environment.
*
* Matching names override inherited values. Set a key to `null` to unset
* an inherited variable.
*/
env?: { [key in string]?: string | null } | null,
/**
* Optional initial PTY size in character cells. Only valid when `tty` is
* true.
*/
size?: CommandExecTerminalSize | null,
/**
* Optional sandbox policy for this command.
*
* Uses the same shape as thread/turn execution sandbox configuration and
* defaults to the user's configured policy when omitted.
*/
sandboxPolicy?: SandboxPolicy | null, };
@@ -0,0 +1,18 @@
// 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 { CommandExecTerminalSize } from "./CommandExecTerminalSize";
/**
* Resize a running PTY-backed `command/exec` session.
*/
export type CommandExecResizeParams = {
/**
* Client-supplied, connection-scoped `processId` from the original
* `command/exec` request.
*/
processId: string,
/**
* New PTY size in character cells.
*/
size: CommandExecTerminalSize, };
@@ -0,0 +1,8 @@
// 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.
/**
* Empty success response for `command/exec/resize`.
*/
export type CommandExecResizeResponse = Record<string, never>;
@@ -2,4 +2,23 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CommandExecResponse = { exitCode: number, stdout: string, stderr: string, };
/**
* Final buffered result for `command/exec`.
*/
export type CommandExecResponse = {
/**
* Process exit code.
*/
exitCode: number,
/**
* Buffered stdout capture.
*
* Empty when stdout was streamed via `command/exec/outputDelta`.
*/
stdout: string,
/**
* Buffered stderr capture.
*
* Empty when stderr was streamed via `command/exec/outputDelta`.
*/
stderr: string, };
@@ -0,0 +1,16 @@
// 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.
/**
* PTY size in character cells for `command/exec` PTY sessions.
*/
export type CommandExecTerminalSize = {
/**
* Terminal height in character cells.
*/
rows: number,
/**
* Terminal width in character cells.
*/
cols: number, };
@@ -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.
/**
* Terminate a running `command/exec` session.
*/
export type CommandExecTerminateParams = {
/**
* Client-supplied, connection-scoped `processId` from the original
* `command/exec` request.
*/
processId: string, };
@@ -0,0 +1,8 @@
// 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.
/**
* Empty success response for `command/exec/terminate`.
*/
export type CommandExecTerminateResponse = Record<string, never>;
@@ -0,0 +1,22 @@
// 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.
/**
* Write stdin bytes to a running `command/exec` session, close stdin, or
* both.
*/
export type CommandExecWriteParams = {
/**
* Client-supplied, connection-scoped `processId` from the original
* `command/exec` request.
*/
processId: string,
/**
* Optional base64-encoded stdin bytes to write.
*/
deltaBase64?: string | null,
/**
* Close stdin after writing `deltaBase64`, if present.
*/
closeStdin?: boolean, };
@@ -0,0 +1,8 @@
// 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.
/**
* Empty success response for `command/exec/write`.
*/
export type CommandExecWriteResponse = Record<string, never>;
@@ -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 { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array<string>, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null, defaultPrompt: string | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array<AbsolutePathBuf>, };
@@ -6,6 +6,6 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginListParams = {
/**
* Optional working directories used to discover repo marketplaces. When omitted,
* only home-scoped marketplaces are considered.
* only home-scoped marketplaces and the official curated marketplace are considered.
*/
cwds?: Array<AbsolutePathBuf> | null, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { PluginSummary } from "./PluginSummary";
export type PluginMarketplaceEntry = { name: string, path: string, plugins: Array<PluginSummary>, };
export type PluginMarketplaceEntry = { name: string, path: AbsolutePathBuf, plugins: Array<PluginSummary>, };
@@ -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 { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginSource = { "type": "local", path: string, };
export type PluginSource = { "type": "local", path: AbsolutePathBuf, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginInterface } from "./PluginInterface";
import type { PluginSource } from "./PluginSource";
export type PluginSummary = { name: string, source: PluginSource, enabled: boolean, };
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, interface: PluginInterface | null, };
@@ -8,5 +8,6 @@ import type { Verbosity } from "../Verbosity";
import type { WebSearchMode } from "../WebSearchMode";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AskForApproval } from "./AskForApproval";
import type { ToolsV2 } from "./ToolsV2";
export type ProfileV2 = { model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, chatgpt_base_url: string | null, } & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
export type ProfileV2 = { model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null, } & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
@@ -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 { WebSearchToolConfig } from "../WebSearchToolConfig";
export type ToolsV2 = { web_search: boolean | null, view_image: boolean | null, };
export type ToolsV2 = { web_search: WebSearchToolConfig | null, view_image: boolean | null, };
@@ -38,8 +38,17 @@ export type { CollabAgentTool } from "./CollabAgentTool";
export type { CollabAgentToolCallStatus } from "./CollabAgentToolCallStatus";
export type { CollaborationModeMask } from "./CollaborationModeMask";
export type { CommandAction } from "./CommandAction";
export type { CommandExecOutputDeltaNotification } from "./CommandExecOutputDeltaNotification";
export type { CommandExecOutputStream } from "./CommandExecOutputStream";
export type { CommandExecParams } from "./CommandExecParams";
export type { CommandExecResizeParams } from "./CommandExecResizeParams";
export type { CommandExecResizeResponse } from "./CommandExecResizeResponse";
export type { CommandExecResponse } from "./CommandExecResponse";
export type { CommandExecTerminalSize } from "./CommandExecTerminalSize";
export type { CommandExecTerminateParams } from "./CommandExecTerminateParams";
export type { CommandExecTerminateResponse } from "./CommandExecTerminateResponse";
export type { CommandExecWriteParams } from "./CommandExecWriteParams";
export type { CommandExecWriteResponse } from "./CommandExecWriteResponse";
export type { CommandExecutionApprovalDecision } from "./CommandExecutionApprovalDecision";
export type { CommandExecutionOutputDeltaNotification } from "./CommandExecutionOutputDeltaNotification";
export type { CommandExecutionRequestApprovalParams } from "./CommandExecutionRequestApprovalParams";
@@ -152,6 +161,7 @@ export type { PatchChangeKind } from "./PatchChangeKind";
export type { PlanDeltaNotification } from "./PlanDeltaNotification";
export type { PluginInstallParams } from "./PluginInstallParams";
export type { PluginInstallResponse } from "./PluginInstallResponse";
export type { PluginInterface } from "./PluginInterface";
export type { PluginListParams } from "./PluginListParams";
export type { PluginListResponse } from "./PluginListResponse";
export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
+71 -2
View File
@@ -1154,13 +1154,40 @@ fn insert_into_namespace(
.or_insert_with(|| Value::Object(Map::new()));
match entry {
Value::Object(map) => {
map.insert(name, schema);
Ok(())
insert_definition(map, name, schema, &format!("namespace `{namespace}`"))
}
_ => Err(anyhow!("expected namespace {namespace} to be an object")),
}
}
fn insert_definition(
definitions: &mut Map<String, Value>,
name: String,
schema: Value,
location: &str,
) -> Result<()> {
if let Some(existing) = definitions.get(&name) {
if existing == &schema {
return Ok(());
}
let existing_title = existing
.get("title")
.and_then(Value::as_str)
.unwrap_or("<untitled>");
let new_title = schema
.get("title")
.and_then(Value::as_str)
.unwrap_or("<untitled>");
return Err(anyhow!(
"schema definition collision in {location}: {name} (existing title: {existing_title}, new title: {new_title}); use #[schemars(rename = \"...\")] to rename one of the conflicting schema definitions"
));
}
definitions.insert(name, schema);
Ok(())
}
fn write_json_schema_with_return<T>(out_dir: &Path, name: &str) -> Result<GeneratedSchema>
where
T: JsonSchema,
@@ -2291,6 +2318,48 @@ mod tests {
Ok(())
}
#[test]
fn build_schema_bundle_rejects_conflicting_duplicate_definitions() {
let err = build_schema_bundle(vec![
GeneratedSchema {
namespace: Some("v2".to_string()),
logical_name: "First".to_string(),
in_v1_dir: false,
value: serde_json::json!({
"title": "First",
"type": "object",
"definitions": {
"Shared": {
"title": "SharedString",
"type": "string"
}
}
}),
},
GeneratedSchema {
namespace: Some("v2".to_string()),
logical_name: "Second".to_string(),
in_v1_dir: false,
value: serde_json::json!({
"title": "Second",
"type": "object",
"definitions": {
"Shared": {
"title": "SharedInteger",
"type": "integer"
}
}
}),
},
])
.expect_err("conflicting schema definitions should be rejected");
assert_eq!(
err.to_string(),
"schema definition collision in namespace `v2`: Shared (existing title: SharedString, new title: SharedInteger); use #[schemars(rename = \"...\")] to rename one of the conflicting schema definitions"
);
}
#[test]
fn build_flat_v2_schema_keeps_shared_root_schemas_and_dependencies() -> Result<()> {
let bundle = serde_json::json!({
@@ -377,11 +377,26 @@ client_request_definitions! {
response: v2::FeedbackUploadResponse,
},
/// Execute a command (argv vector) under the server's sandbox.
/// Execute a standalone command (argv vector) under the server's sandbox.
OneOffCommandExec => "command/exec" {
params: v2::CommandExecParams,
response: v2::CommandExecResponse,
},
/// Write stdin bytes to a running `command/exec` session or close stdin.
CommandExecWrite => "command/exec/write" {
params: v2::CommandExecWriteParams,
response: v2::CommandExecWriteResponse,
},
/// Terminate a running `command/exec` session by client-supplied `processId`.
CommandExecTerminate => "command/exec/terminate" {
params: v2::CommandExecTerminateParams,
response: v2::CommandExecTerminateResponse,
},
/// Resize a running PTY-backed `command/exec` session by client-supplied `processId`.
CommandExecResize => "command/exec/resize" {
params: v2::CommandExecResizeParams,
response: v2::CommandExecResizeResponse,
},
ConfigRead => "config/read" {
params: v2::ConfigReadParams,
@@ -781,6 +796,8 @@ server_notification_definitions! {
AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification),
/// EXPERIMENTAL - proposed plan streaming deltas for plan items.
PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification),
/// Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.
CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification),
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification),
@@ -1,14 +1,22 @@
use crate::protocol::v1;
use crate::protocol::v2;
impl From<v1::ExecOneOffCommandParams> for v2::CommandExecParams {
fn from(value: v1::ExecOneOffCommandParams) -> Self {
Self {
command: value.command,
process_id: None,
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: value
.timeout_ms
.map(|timeout| i64::try_from(timeout).unwrap_or(60_000)),
cwd: value.cwd,
env: None,
size: None,
sandbox_policy: value.sandbox_policy.map(std::convert::Into::into),
}
}
+498 -5
View File
@@ -22,6 +22,7 @@ use codex_protocol::config_types::SandboxMode as CoreSandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::mcp::Resource as McpResource;
@@ -375,8 +376,7 @@ pub struct SandboxWorkspaceWrite {
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct ToolsV2 {
#[serde(alias = "web_search_request")]
pub web_search: Option<bool>,
pub web_search: Option<WebSearchToolConfig>,
pub view_image: Option<bool>,
}
@@ -401,6 +401,7 @@ pub struct ProfileV2 {
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub web_search: Option<WebSearchMode>,
pub tools: Option<ToolsV2>,
pub chatgpt_base_url: Option<String>,
#[serde(default, flatten)]
pub additional: HashMap<String, JsonValue>,
@@ -1804,29 +1805,184 @@ pub struct FeedbackUploadResponse {
pub thread_id: String,
}
/// PTY size in character cells for `command/exec` PTY sessions.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecTerminalSize {
/// Terminal height in character cells.
pub rows: u16,
/// Terminal width in character cells.
pub cols: u16,
}
/// Run a standalone command (argv vector) in the server sandbox without
/// creating a thread or turn.
///
/// The final `command/exec` response is deferred until the process exits and is
/// sent only after all `command/exec/outputDelta` notifications for that
/// connection have been emitted.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecParams {
/// Command argv vector. Empty arrays are rejected.
pub command: Vec<String>,
/// Optional client-supplied, connection-scoped process id.
///
/// Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up
/// `command/exec/write`, `command/exec/resize`, and
/// `command/exec/terminate` calls. When omitted, buffered execution gets an
/// internal id that is not exposed to the client.
#[ts(optional = nullable)]
pub process_id: Option<String>,
/// Enable PTY mode.
///
/// This implies `streamStdin` and `streamStdoutStderr`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub tty: bool,
/// Allow follow-up `command/exec/write` requests to write stdin bytes.
///
/// Requires a client-supplied `processId`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stream_stdin: bool,
/// Stream stdout/stderr via `command/exec/outputDelta` notifications.
///
/// Streamed bytes are not duplicated into the final response and require a
/// client-supplied `processId`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stream_stdout_stderr: bool,
/// Optional per-stream stdout/stderr capture cap in bytes.
///
/// When omitted, the server default applies. Cannot be combined with
/// `disableOutputCap`.
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub output_bytes_cap: Option<usize>,
/// Disable stdout/stderr capture truncation for this request.
///
/// Cannot be combined with `outputBytesCap`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub disable_output_cap: bool,
/// Disable the timeout entirely for this request.
///
/// Cannot be combined with `timeoutMs`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub disable_timeout: bool,
/// Optional timeout in milliseconds.
///
/// When omitted, the server default applies. Cannot be combined with
/// `disableTimeout`.
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub timeout_ms: Option<i64>,
/// Optional working directory. Defaults to the server cwd.
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
/// Optional environment overrides merged into the server-computed
/// environment.
///
/// Matching names override inherited values. Set a key to `null` to unset
/// an inherited variable.
#[ts(optional = nullable)]
pub env: Option<HashMap<String, Option<String>>>,
/// Optional initial PTY size in character cells. Only valid when `tty` is
/// true.
#[ts(optional = nullable)]
pub size: Option<CommandExecTerminalSize>,
/// Optional sandbox policy for this command.
///
/// Uses the same shape as thread/turn execution sandbox configuration and
/// defaults to the user's configured policy when omitted.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
}
/// Final buffered result for `command/exec`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecResponse {
/// Process exit code.
pub exit_code: i32,
/// Buffered stdout capture.
///
/// Empty when stdout was streamed via `command/exec/outputDelta`.
pub stdout: String,
/// Buffered stderr capture.
///
/// Empty when stderr was streamed via `command/exec/outputDelta`.
pub stderr: String,
}
/// Write stdin bytes to a running `command/exec` session, close stdin, or
/// both.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecWriteParams {
/// Client-supplied, connection-scoped `processId` from the original
/// `command/exec` request.
pub process_id: String,
/// Optional base64-encoded stdin bytes to write.
#[ts(optional = nullable)]
pub delta_base64: Option<String>,
/// Close stdin after writing `deltaBase64`, if present.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub close_stdin: bool,
}
/// Empty success response for `command/exec/write`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecWriteResponse {}
/// Terminate a running `command/exec` session.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecTerminateParams {
/// Client-supplied, connection-scoped `processId` from the original
/// `command/exec` request.
pub process_id: String,
}
/// Empty success response for `command/exec/terminate`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecTerminateResponse {}
/// Resize a running PTY-backed `command/exec` session.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecResizeParams {
/// Client-supplied, connection-scoped `processId` from the original
/// `command/exec` request.
pub process_id: String,
/// New PTY size in character cells.
pub size: CommandExecTerminalSize,
}
/// Empty success response for `command/exec/resize`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecResizeResponse {}
/// Stream label for `command/exec/outputDelta` notifications.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum CommandExecOutputStream {
/// stdout stream. PTY mode multiplexes terminal output here.
Stdout,
/// stderr stream.
Stderr,
}
// === Threads, Turns, and Items ===
// Thread APIs
#[derive(
@@ -2397,7 +2553,7 @@ pub struct SkillsListResponse {
#[ts(export_to = "v2/")]
pub struct PluginListParams {
/// Optional working directories used to discover repo marketplaces. When omitted,
/// only home-scoped marketplaces are considered.
/// only home-scoped marketplaces and the official curated marketplace are considered.
#[ts(optional = nullable)]
pub cwds: Option<Vec<AbsolutePathBuf>>,
}
@@ -2577,7 +2733,7 @@ pub struct SkillsListEntry {
#[ts(export_to = "v2/")]
pub struct PluginMarketplaceEntry {
pub name: String,
pub path: PathBuf,
pub path: AbsolutePathBuf,
pub plugins: Vec<PluginSummary>,
}
@@ -2585,9 +2741,32 @@ pub struct PluginMarketplaceEntry {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSummary {
pub id: String,
pub name: String,
pub source: PluginSource,
pub installed: bool,
pub enabled: bool,
pub interface: Option<PluginInterface>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginInterface {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub long_description: Option<String>,
pub developer_name: Option<String>,
pub category: Option<String>,
pub capabilities: Vec<String>,
pub website_url: Option<String>,
pub privacy_policy_url: Option<String>,
pub terms_of_service_url: Option<String>,
pub default_prompt: Option<String>,
pub brand_color: Option<String>,
pub composer_icon: Option<AbsolutePathBuf>,
pub logo: Option<AbsolutePathBuf>,
pub screenshots: Vec<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -2597,7 +2776,7 @@ pub struct PluginSummary {
pub enum PluginSource {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Local { path: PathBuf },
Local { path: AbsolutePathBuf },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -3965,6 +4144,26 @@ pub struct CommandExecutionOutputDeltaNotification {
pub delta: String,
}
/// Base64-encoded output chunk emitted for a streaming `command/exec` request.
///
/// These notifications are connection-scoped. If the originating connection
/// closes, the server terminates the process.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecOutputDeltaNotification {
/// Client-supplied, connection-scoped `processId` from the original
/// `command/exec` request.
pub process_id: String,
/// Output stream for this chunk.
pub stream: CommandExecOutputStream,
/// Base64-encoded output bytes.
pub delta_base64: String,
/// `true` on the final streamed chunk for a stream when `outputBytesCap`
/// truncated later output on that stream.
pub cap_reached: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -4947,6 +5146,300 @@ mod tests {
);
}
#[test]
fn command_exec_params_default_optional_streaming_flags() {
let params = serde_json::from_value::<CommandExecParams>(json!({
"command": ["ls", "-la"],
"timeoutMs": 1000,
"cwd": "/tmp"
}))
.expect("command/exec payload should deserialize");
assert_eq!(
params,
CommandExecParams {
command: vec!["ls".to_string(), "-la".to_string()],
process_id: None,
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: Some(1000),
cwd: Some(PathBuf::from("/tmp")),
env: None,
size: None,
sandbox_policy: None,
}
);
}
#[test]
fn command_exec_params_round_trips_disable_timeout() {
let params = CommandExecParams {
command: vec!["sleep".to_string(), "30".to_string()],
process_id: Some("sleep-1".to_string()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: true,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
};
let value = serde_json::to_value(&params).expect("serialize command/exec params");
assert_eq!(
value,
json!({
"command": ["sleep", "30"],
"processId": "sleep-1",
"disableTimeout": true,
"timeoutMs": null,
"cwd": null,
"env": null,
"size": null,
"sandboxPolicy": null,
"outputBytesCap": null,
})
);
let decoded =
serde_json::from_value::<CommandExecParams>(value).expect("deserialize round-trip");
assert_eq!(decoded, params);
}
#[test]
fn command_exec_params_round_trips_disable_output_cap() {
let params = CommandExecParams {
command: vec!["yes".to_string()],
process_id: Some("yes-1".to_string()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: true,
output_bytes_cap: None,
disable_output_cap: true,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
};
let value = serde_json::to_value(&params).expect("serialize command/exec params");
assert_eq!(
value,
json!({
"command": ["yes"],
"processId": "yes-1",
"streamStdoutStderr": true,
"outputBytesCap": null,
"disableOutputCap": true,
"timeoutMs": null,
"cwd": null,
"env": null,
"size": null,
"sandboxPolicy": null,
})
);
let decoded =
serde_json::from_value::<CommandExecParams>(value).expect("deserialize round-trip");
assert_eq!(decoded, params);
}
#[test]
fn command_exec_params_round_trips_env_overrides_and_unsets() {
let params = CommandExecParams {
command: vec!["printenv".to_string(), "FOO".to_string()],
process_id: Some("env-1".to_string()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: Some(HashMap::from([
("FOO".to_string(), Some("override".to_string())),
("BAR".to_string(), Some("added".to_string())),
("BAZ".to_string(), None),
])),
size: None,
sandbox_policy: None,
};
let value = serde_json::to_value(&params).expect("serialize command/exec params");
assert_eq!(
value,
json!({
"command": ["printenv", "FOO"],
"processId": "env-1",
"outputBytesCap": null,
"timeoutMs": null,
"cwd": null,
"env": {
"FOO": "override",
"BAR": "added",
"BAZ": null,
},
"size": null,
"sandboxPolicy": null,
})
);
let decoded =
serde_json::from_value::<CommandExecParams>(value).expect("deserialize round-trip");
assert_eq!(decoded, params);
}
#[test]
fn command_exec_write_round_trips_close_only_payload() {
let params = CommandExecWriteParams {
process_id: "proc-7".to_string(),
delta_base64: None,
close_stdin: true,
};
let value = serde_json::to_value(&params).expect("serialize command/exec/write params");
assert_eq!(
value,
json!({
"processId": "proc-7",
"deltaBase64": null,
"closeStdin": true,
})
);
let decoded = serde_json::from_value::<CommandExecWriteParams>(value)
.expect("deserialize round-trip");
assert_eq!(decoded, params);
}
#[test]
fn command_exec_terminate_round_trips() {
let params = CommandExecTerminateParams {
process_id: "proc-8".to_string(),
};
let value = serde_json::to_value(&params).expect("serialize command/exec/terminate params");
assert_eq!(
value,
json!({
"processId": "proc-8",
})
);
let decoded = serde_json::from_value::<CommandExecTerminateParams>(value)
.expect("deserialize round-trip");
assert_eq!(decoded, params);
}
#[test]
fn command_exec_params_round_trip_with_size() {
let params = CommandExecParams {
command: vec!["top".to_string()],
process_id: Some("pty-1".to_string()),
tty: true,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: Some(CommandExecTerminalSize {
rows: 40,
cols: 120,
}),
sandbox_policy: None,
};
let value = serde_json::to_value(&params).expect("serialize command/exec params");
assert_eq!(
value,
json!({
"command": ["top"],
"processId": "pty-1",
"tty": true,
"outputBytesCap": null,
"timeoutMs": null,
"cwd": null,
"env": null,
"size": {
"rows": 40,
"cols": 120,
},
"sandboxPolicy": null,
})
);
let decoded =
serde_json::from_value::<CommandExecParams>(value).expect("deserialize round-trip");
assert_eq!(decoded, params);
}
#[test]
fn command_exec_resize_round_trips() {
let params = CommandExecResizeParams {
process_id: "proc-9".to_string(),
size: CommandExecTerminalSize {
rows: 50,
cols: 160,
},
};
let value = serde_json::to_value(&params).expect("serialize command/exec/resize params");
assert_eq!(
value,
json!({
"processId": "proc-9",
"size": {
"rows": 50,
"cols": 160,
},
})
);
let decoded = serde_json::from_value::<CommandExecResizeParams>(value)
.expect("deserialize round-trip");
assert_eq!(decoded, params);
}
#[test]
fn command_exec_output_delta_round_trips() {
let notification = CommandExecOutputDeltaNotification {
process_id: "proc-1".to_string(),
stream: CommandExecOutputStream::Stdout,
delta_base64: "AQI=".to_string(),
cap_reached: false,
};
let value = serde_json::to_value(&notification)
.expect("serialize command/exec/outputDelta notification");
assert_eq!(
value,
json!({
"processId": "proc-1",
"stream": "stdout",
"deltaBase64": "AQI=",
"capReached": false,
})
);
let decoded = serde_json::from_value::<CommandExecOutputDeltaNotification>(value)
.expect("deserialize round-trip");
assert_eq!(decoded, notification);
}
#[test]
fn sandbox_policy_round_trips_external_sandbox_network_access() {
let v2_policy = SandboxPolicy::ExternalSandbox {
@@ -96,6 +96,7 @@ const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[
"codex/event/item_started",
"codex/event/item_completed",
// v2 item deltas.
"command/exec/outputDelta",
"item/agentMessage/delta",
"item/plan/delta",
"item/commandExecution/outputDelta",
+2 -1
View File
@@ -18,12 +18,14 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
codex-arg0 = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-core = { workspace = true }
codex-otel = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-cli = { workspace = true }
codex-utils-pty = { workspace = true }
codex-backend-client = { workspace = true }
codex-file-search = { workspace = true }
codex-chatgpt = { workspace = true }
@@ -64,7 +66,6 @@ axum = { workspace = true, default-features = false, features = [
"json",
"tokio",
] }
base64 = { workspace = true }
core_test_support = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
+81 -5
View File
@@ -144,24 +144,27 @@ Example with notification opt-out:
- `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`.
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `command/exec/write` — write base64-decoded stdin bytes to a running `command/exec` session or close stdin; returns `{}`.
- `command/exec/resize` — resize a running PTY-backed `command/exec` session by `processId`; returns `{}`.
- `command/exec/terminate` — terminate a running `command/exec` session by `processId`; returns `{}`.
- `command/exec/outputDelta` — notification emitted for base64-encoded stdout/stderr chunks from a streaming `command/exec` session.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `plugin/list` — list discovered marketplaces reachable from optional `cwds` (unioned into a single list). When `cwds` is omitted, only home-scoped marketplaces are considered. Includes each plugin's current `enabled` state from config (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces, including plugin id, installed/enabled state, and optional interface metadata (**under development; do not call from production clients yet**).
- `skills/changed` — notification emitted when watched local skill files change.
- `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**).
- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**).
- `app/list` — list available apps.
- `skills/config/write` — write user-level skill config by path.
- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplacePath`; on success it returns `appsNeedingAuth` for any plugin-declared apps that still are not accessible in the current ChatGPT auth context (**under development; do not call from production clients yet**).
- `plugin/install` — install a plugin from a discovered marketplace entry and return any apps that still need auth (**under development; do not call from production clients yet**).
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `config/read` — fetch the effective config on disk after resolving config layering.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home).
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home).
@@ -613,11 +616,21 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin
```json
{ "method": "command/exec", "id": 32, "params": {
"command": ["ls", "-la"],
"processId": "ls-1", // optional string; required for streaming and ability to terminate the process
"cwd": "/Users/me/project", // optional; defaults to server cwd
"env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names
"size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true
"sandboxPolicy": { "type": "workspaceWrite" }, // optional; defaults to user config
"timeoutMs": 10000 // optional; ms timeout; defaults to server timeout
"outputBytesCap": 1048576, // optional; per-stream capture cap
"disableOutputCap": false, // optional; cannot be combined with outputBytesCap
"timeoutMs": 10000, // optional; ms timeout; defaults to server timeout
"disableTimeout": false // optional; cannot be combined with timeoutMs
} }
{ "id": 32, "result": {
"exitCode": 0,
"stdout": "...",
"stderr": ""
} }
{ "id": 32, "result": { "exitCode": 0, "stdout": "...", "stderr": "" } }
```
- For clients that are already sandboxed externally, set `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`.
@@ -626,7 +639,70 @@ Notes:
- Empty `command` arrays are rejected.
- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`).
- `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact.
- When omitted, `timeoutMs` falls back to the server default.
- When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream.
- `disableOutputCap: true` disables stdout/stderr capture truncation for that `command/exec` request. It cannot be combined with `outputBytesCap`.
- `disableTimeout: true` disables the timeout entirely for that `command/exec` request. It cannot be combined with `timeoutMs`.
- `processId` is optional for buffered execution. When omitted, Codex generates an internal id for lifecycle tracking, but `tty`, `streamStdin`, and `streamStdoutStderr` must stay disabled and follow-up `command/exec/write` / `command/exec/terminate` calls are not available for that command.
- `size` is only valid when `tty: true`. It sets the initial PTY size in character cells.
- Buffered Windows sandbox execution accepts `processId` for correlation, but `command/exec/write` and `command/exec/terminate` are still unsupported for those requests.
- Buffered Windows sandbox execution also requires the default output cap; custom `outputBytesCap` and `disableOutputCap` are unsupported there.
- `tty`, `streamStdin`, and `streamStdoutStderr` are optional booleans. Legacy requests that omit them continue to use buffered execution.
- `tty: true` implies PTY mode plus `streamStdin: true` and `streamStdoutStderr: true`.
- `tty` and `streamStdin` do not disable the timeout on their own; omit `timeoutMs` to use the server default timeout, or set `disableTimeout: true` to keep the process alive until exit or explicit termination.
- `outputBytesCap` applies independently to `stdout` and `stderr`, and streamed bytes are not duplicated into the final response.
- The `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.
- `command/exec/outputDelta` notifications are connection-scoped. If the originating connection closes, the server terminates the process.
Streaming stdin/stdout uses base64 so PTY sessions can carry arbitrary bytes:
```json
{ "method": "command/exec", "id": 33, "params": {
"command": ["bash", "-i"],
"processId": "bash-1",
"tty": true,
"outputBytesCap": 32768
} }
{ "method": "command/exec/outputDelta", "params": {
"processId": "bash-1",
"stream": "stdout",
"deltaBase64": "YmFzaC00LjQkIA==",
"capReached": false
} }
{ "method": "command/exec/write", "id": 34, "params": {
"processId": "bash-1",
"deltaBase64": "cHdkCg=="
} }
{ "id": 34, "result": {} }
{ "method": "command/exec/write", "id": 35, "params": {
"processId": "bash-1",
"closeStdin": true
} }
{ "id": 35, "result": {} }
{ "method": "command/exec/resize", "id": 36, "params": {
"processId": "bash-1",
"size": { "rows": 48, "cols": 160 }
} }
{ "id": 36, "result": {} }
{ "method": "command/exec/terminate", "id": 37, "params": {
"processId": "bash-1"
} }
{ "id": 37, "result": {} }
{ "id": 33, "result": {
"exitCode": 137,
"stdout": "",
"stderr": ""
} }
```
- `command/exec/write` accepts either `deltaBase64`, `closeStdin`, or both.
- Clients may supply a connection-scoped string `processId` in `command/exec`; `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` only accept those client-supplied string ids.
- `command/exec/outputDelta.processId` is always the client-supplied string id from the original `command/exec` request.
- `command/exec/outputDelta.stream` is `stdout` or `stderr`. PTY mode multiplexes terminal output through `stdout`.
- `command/exec/outputDelta.capReached` is `true` on the final streamed chunk for a stream when `outputBytesCap` truncates that stream; later output on that stream is dropped.
- `command/exec.params.env` overrides the server-computed environment per key; set a key to `null` to unset an inherited variable.
- `command/exec/resize` is only supported for PTY-backed `command/exec` sessions.
## Events
@@ -1,4 +1,6 @@
use crate::bespoke_event_handling::apply_bespoke_event_handling;
use crate::command_exec::CommandExecManager;
use crate::command_exec::StartCommandExecParams;
use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_PARAMS_ERROR_CODE;
@@ -34,10 +36,12 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CollaborationModeListParams;
use codex_app_server_protocol::CollaborationModeListResponse;
use codex_app_server_protocol::CommandExecParams;
use codex_app_server_protocol::CommandExecResizeParams;
use codex_app_server_protocol::CommandExecTerminateParams;
use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec;
use codex_app_server_protocol::ExecOneOffCommandResponse;
use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature;
use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::ExperimentalFeatureListResponse;
@@ -80,6 +84,7 @@ use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
@@ -192,6 +197,7 @@ use codex_core::connectors::filter_disallowed_connectors;
use codex_core::connectors::merge_plugin_apps;
use codex_core::default_client::set_default_client_residency_requirement;
use codex_core::error::CodexErr;
use codex_core::exec::ExecExpiration;
use codex_core::exec::ExecParams;
use codex_core::exec_env::create_env;
use codex_core::features::FEATURES;
@@ -263,6 +269,7 @@ use codex_state::StateRuntime;
use codex_state::ThreadMetadataBuilder;
use codex_state::log_db::LogDbLayer;
use codex_utils_json_to_toml::json_to_toml;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
@@ -281,6 +288,7 @@ use tokio::sync::Mutex;
use tokio::sync::broadcast;
use tokio::sync::oneshot;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use toml::Value as TomlValue;
use tracing::error;
use tracing::info;
@@ -368,6 +376,7 @@ pub(crate) struct CodexMessageProcessor {
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
thread_state_manager: ThreadStateManager,
thread_watch_manager: ThreadWatchManager,
command_exec_manager: CommandExecManager,
pending_fuzzy_searches: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
fuzzy_search_sessions: Arc<Mutex<HashMap<String, FuzzyFileSearchSession>>>,
feedback: CodexFeedback,
@@ -411,6 +420,21 @@ pub(crate) struct CodexMessageProcessorArgs {
}
impl CodexMessageProcessor {
pub(crate) fn clear_plugin_related_caches(&self) {
self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_manager().clear_cache();
}
pub(crate) async fn maybe_start_curated_repo_sync_for_latest_config(&self) {
match self.load_latest_config(None).await {
Ok(config) => self
.thread_manager
.plugins_manager()
.maybe_start_curated_repo_sync_for_config(&config),
Err(err) => warn!("failed to load latest config for curated plugin sync: {err:?}"),
}
}
fn current_account_updated_notification(&self) -> AccountUpdatedNotification {
let auth = self.auth_manager.auth_cached();
AccountUpdatedNotification {
@@ -466,6 +490,7 @@ impl CodexMessageProcessor {
pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())),
thread_state_manager: ThreadStateManager::new(),
thread_watch_manager: ThreadWatchManager::new_with_outgoing(outgoing),
command_exec_manager: CommandExecManager::default(),
pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())),
fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())),
feedback,
@@ -815,6 +840,18 @@ impl CodexMessageProcessor {
self.exec_one_off_command(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::CommandExecWrite { request_id, params } => {
self.command_exec_write(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::CommandExecResize { request_id, params } => {
self.command_exec_resize(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::CommandExecTerminate { request_id, params } => {
self.command_exec_terminate(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ConfigRead { .. }
| ClientRequest::ConfigValueWrite { .. }
| ClientRequest::ConfigBatchWrite { .. } => {
@@ -1487,11 +1524,84 @@ impl CodexMessageProcessor {
return;
}
let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone());
let env = create_env(&self.config.permissions.shell_environment_policy, None);
let timeout_ms = params
.timeout_ms
.and_then(|timeout_ms| u64::try_from(timeout_ms).ok());
let CommandExecParams {
command,
process_id,
tty,
stream_stdin,
stream_stdout_stderr,
output_bytes_cap,
disable_output_cap,
disable_timeout,
timeout_ms,
cwd,
env: env_overrides,
size,
sandbox_policy,
} = params;
if size.is_some() && !tty {
let error = JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: "command/exec size requires tty: true".to_string(),
data: None,
};
self.outgoing.send_error(request, error).await;
return;
}
if disable_output_cap && output_bytes_cap.is_some() {
let error = JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: "command/exec cannot set both outputBytesCap and disableOutputCap"
.to_string(),
data: None,
};
self.outgoing.send_error(request, error).await;
return;
}
if disable_timeout && timeout_ms.is_some() {
let error = JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: "command/exec cannot set both timeoutMs and disableTimeout".to_string(),
data: None,
};
self.outgoing.send_error(request, error).await;
return;
}
let cwd = cwd.unwrap_or_else(|| self.config.cwd.clone());
let mut env = create_env(&self.config.permissions.shell_environment_policy, None);
if let Some(env_overrides) = env_overrides {
for (key, value) in env_overrides {
match value {
Some(value) => {
env.insert(key, value);
}
None => {
env.remove(&key);
}
}
}
}
let timeout_ms = match timeout_ms {
Some(timeout_ms) => match u64::try_from(timeout_ms) {
Ok(timeout_ms) => Some(timeout_ms),
Err(_) => {
let error = JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: format!(
"command/exec timeoutMs must be non-negative, got {timeout_ms}"
),
data: None,
};
self.outgoing.send_error(request, error).await;
return;
}
},
None => None,
};
let managed_network_requirements_enabled =
self.config.managed_network_requirements_enabled();
let started_network_proxy = match self.config.permissions.network.as_ref() {
@@ -1519,10 +1629,23 @@ impl CodexMessageProcessor {
None => None,
};
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
let output_bytes_cap = if disable_output_cap {
None
} else {
Some(output_bytes_cap.unwrap_or(DEFAULT_OUTPUT_BYTES_CAP))
};
let expiration = if disable_timeout {
ExecExpiration::Cancellation(CancellationToken::new())
} else {
match timeout_ms {
Some(timeout_ms) => timeout_ms.into(),
None => ExecExpiration::DefaultTimeout,
}
};
let exec_params = ExecParams {
command: params.command,
command,
cwd,
expiration: timeout_ms.into(),
expiration,
env,
network: started_network_proxy
.as_ref()
@@ -1533,10 +1656,20 @@ impl CodexMessageProcessor {
arg0: None,
};
let requested_policy = params.sandbox_policy.map(|policy| policy.to_core());
let effective_policy = match requested_policy {
let requested_policy = sandbox_policy.map(|policy| policy.to_core());
let (
effective_policy,
effective_file_system_sandbox_policy,
effective_network_sandbox_policy,
) = match requested_policy {
Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) {
Ok(()) => policy,
Ok(()) => {
let file_system_sandbox_policy =
codex_protocol::permissions::FileSystemSandboxPolicy::from(&policy);
let network_sandbox_policy =
codex_protocol::permissions::NetworkSandboxPolicy::from(&policy);
(policy, file_system_sandbox_policy, network_sandbox_policy)
}
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
@@ -1547,46 +1680,111 @@ impl CodexMessageProcessor {
return;
}
},
None => self.config.permissions.sandbox_policy.get().clone(),
None => (
self.config.permissions.sandbox_policy.get().clone(),
self.config.permissions.file_system_sandbox_policy.clone(),
self.config.permissions.network_sandbox_policy,
),
};
let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
let outgoing = self.outgoing.clone();
let request_for_task = request;
let request_for_task = request.clone();
let sandbox_cwd = self.config.cwd.clone();
let started_network_proxy_for_task = started_network_proxy;
let use_linux_sandbox_bwrap = self.config.features.enabled(Feature::UseLinuxSandboxBwrap);
let size = match size.map(crate::command_exec::terminal_size_from_protocol) {
Some(Ok(size)) => Some(size),
Some(Err(error)) => {
self.outgoing.send_error(request, error).await;
return;
}
None => None,
};
tokio::spawn(async move {
let _started_network_proxy = started_network_proxy_for_task;
match codex_core::exec::process_exec_tool_call(
exec_params,
&effective_policy,
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
use_linux_sandbox_bwrap,
None,
)
.await
{
Ok(output) => {
let response = ExecOneOffCommandResponse {
exit_code: output.exit_code,
stdout: output.stdout.text,
stderr: output.stderr.text,
};
outgoing.send_response(request_for_task, response).await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("exec failed: {err}"),
data: None,
};
outgoing.send_error(request_for_task, error).await;
match codex_core::exec::build_exec_request(
exec_params,
&effective_policy,
&effective_file_system_sandbox_policy,
effective_network_sandbox_policy,
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
use_linux_sandbox_bwrap,
) {
Ok(exec_request) => {
if let Err(error) = self
.command_exec_manager
.start(StartCommandExecParams {
outgoing,
request_id: request_for_task,
process_id,
exec_request,
started_network_proxy: started_network_proxy_for_task,
tty,
stream_stdin,
stream_stdout_stderr,
output_bytes_cap,
size,
})
.await
{
self.outgoing.send_error(request, error).await;
}
}
});
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("exec failed: {err}"),
data: None,
};
self.outgoing.send_error(request, error).await;
}
}
}
async fn command_exec_write(
&self,
request_id: ConnectionRequestId,
params: CommandExecWriteParams,
) {
match self
.command_exec_manager
.write(request_id.clone(), params)
.await
{
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn command_exec_resize(
&self,
request_id: ConnectionRequestId,
params: CommandExecResizeParams,
) {
match self
.command_exec_manager
.resize(request_id.clone(), params)
.await
{
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn command_exec_terminate(
&self,
request_id: ConnectionRequestId,
params: CommandExecTerminateParams,
) {
match self
.command_exec_manager
.terminate(request_id.clone(), params)
.await
{
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn thread_start(&self, request_id: ConnectionRequestId, params: ThreadStartParams) {
@@ -2856,6 +3054,9 @@ impl CodexMessageProcessor {
}
pub(crate) async fn connection_closed(&mut self, connection_id: ConnectionId) {
self.command_exec_manager
.connection_closed(connection_id)
.await;
self.thread_state_manager
.remove_connection(connection_id)
.await;
@@ -5022,6 +5223,8 @@ impl CodexMessageProcessor {
.plugins
.into_iter()
.map(|plugin| PluginSummary {
id: plugin.id,
installed: plugin.installed,
enabled: plugin.enabled,
name: plugin.name,
source: match plugin.source {
@@ -5029,6 +5232,22 @@ impl CodexMessageProcessor {
PluginSource::Local { path }
}
},
interface: plugin.interface.map(|interface| PluginInterface {
display_name: interface.display_name,
short_description: interface.short_description,
long_description: interface.long_description,
developer_name: interface.developer_name,
category: interface.category,
capabilities: interface.capabilities,
website_url: interface.website_url,
privacy_policy_url: interface.privacy_policy_url,
terms_of_service_url: interface.terms_of_service_url,
default_prompt: interface.default_prompt,
brand_color: interface.brand_color,
composer_icon: interface.composer_icon,
logo: interface.logo,
screenshots: interface.screenshots,
}),
})
.collect(),
})
@@ -5190,7 +5409,7 @@ impl CodexMessageProcessor {
self.config.as_ref().clone()
}
};
let plugin_apps = load_plugin_apps(&result.installed_path);
let plugin_apps = load_plugin_apps(result.installed_path.as_path());
let apps_needing_auth = if plugin_apps.is_empty()
|| !config.features.enabled(Feature::Apps)
{
@@ -5254,8 +5473,7 @@ impl CodexMessageProcessor {
)
};
plugins_manager.clear_cache();
self.thread_manager.skills_manager().clear_cache();
self.clear_plugin_related_caches();
self.outgoing
.send_response(request_id, PluginInstallResponse { apps_needing_auth })
.await;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -231,6 +231,7 @@ mod tests {
dangerously_allow_non_loopback_proxy: Some(false),
dangerously_allow_all_unix_sockets: Some(true),
allowed_domains: Some(vec!["api.openai.com".to_string()]),
managed_allowed_domains_only: Some(false),
denied_domains: Some(vec!["example.com".to_string()]),
allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]),
allow_local_binding: Some(true),
+1
View File
@@ -58,6 +58,7 @@ use tracing_subscriber::util::SubscriberInitExt;
mod app_server_tracing;
mod bespoke_event_handling;
mod codex_message_processor;
mod command_exec;
mod config_api;
mod dynamic_tools;
mod error_code;
+17 -2
View File
@@ -194,6 +194,9 @@ impl MessageProcessor {
.enabled(codex_core::features::Feature::DefaultModeRequestUserInput),
},
));
thread_manager
.plugins_manager()
.maybe_start_curated_repo_sync_for_config(&config);
let cloud_requirements = Arc::new(RwLock::new(cloud_requirements));
let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs {
auth_manager,
@@ -522,7 +525,13 @@ impl MessageProcessor {
params: ConfigValueWriteParams,
) {
match self.config_api.write_value(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Ok(response) => {
self.codex_message_processor.clear_plugin_related_caches();
self.codex_message_processor
.maybe_start_curated_repo_sync_for_latest_config()
.await;
self.outgoing.send_response(request_id, response).await;
}
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
@@ -533,7 +542,13 @@ impl MessageProcessor {
params: ConfigBatchWriteParams,
) {
match self.config_api.batch_write(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Ok(response) => {
self.codex_message_processor.clear_plugin_related_caches();
self.codex_message_processor
.maybe_start_curated_repo_sync_for_latest_config()
.await;
self.outgoing.send_response(request_id, response).await;
}
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
@@ -16,6 +16,10 @@ use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::CollaborationModeListParams;
use codex_app_server_protocol::CommandExecParams;
use codex_app_server_protocol::CommandExecResizeParams;
use codex_app_server_protocol::CommandExecTerminateParams;
use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
@@ -494,6 +498,42 @@ impl McpProcess {
self.send_request("turn/start", params).await
}
/// Send a `command/exec` JSON-RPC request (v2).
pub async fn send_command_exec_request(
&mut self,
params: CommandExecParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("command/exec", params).await
}
/// Send a `command/exec/write` JSON-RPC request (v2).
pub async fn send_command_exec_write_request(
&mut self,
params: CommandExecWriteParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("command/exec/write", params).await
}
/// Send a `command/exec/resize` JSON-RPC request (v2).
pub async fn send_command_exec_resize_request(
&mut self,
params: CommandExecResizeParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("command/exec/resize", params).await
}
/// Send a `command/exec/terminate` JSON-RPC request (v2).
pub async fn send_command_exec_terminate_request(
&mut self,
params: CommandExecTerminateParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("command/exec/terminate", params).await
}
/// Send a `turn/interrupt` JSON-RPC request (v2).
pub async fn send_turn_interrupt_request(
&mut self,
@@ -0,0 +1,880 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::CommandExecOutputDeltaNotification;
use codex_app_server_protocol::CommandExecOutputStream;
use codex_app_server_protocol::CommandExecParams;
use codex_app_server_protocol::CommandExecResizeParams;
use codex_app_server_protocol::CommandExecResponse;
use codex_app_server_protocol::CommandExecTerminalSize;
use codex_app_server_protocol::CommandExecTerminateParams;
use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::Instant;
use tokio::time::sleep;
use tokio::time::timeout;
use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT;
use super::connection_handling_websocket::assert_no_message;
use super::connection_handling_websocket::connect_websocket;
use super::connection_handling_websocket::create_config_toml;
use super::connection_handling_websocket::read_jsonrpc_message;
use super::connection_handling_websocket::reserve_local_addr;
use super::connection_handling_websocket::send_initialize_request;
use super::connection_handling_websocket::send_request;
use super::connection_handling_websocket::spawn_websocket_server;
#[tokio::test]
async fn command_exec_without_streams_can_be_terminated() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "sleep-1".to_string();
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()],
process_id: Some(process_id.clone()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
})
.await?;
let terminate_request_id = mcp
.send_command_exec_terminate_request(CommandExecTerminateParams { process_id })
.await?;
let terminate_response = mcp
.read_stream_until_response_message(RequestId::Integer(terminate_request_id))
.await?;
assert_eq!(terminate_response.result, serde_json::json!({}));
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_ne!(
response.exit_code, 0,
"terminated command should not succeed"
);
assert_eq!(response.stdout, "");
assert_eq!(response.stderr, "");
Ok(())
}
#[tokio::test]
async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
"sh".to_string(),
"-lc".to_string(),
"printf 'legacy-out'; printf 'legacy-err' >&2".to_string(),
],
process_id: None,
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
})
.await?;
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_eq!(
response,
CommandExecResponse {
exit_code: 0,
stdout: "legacy-out".to_string(),
stderr: "legacy-err".to_string(),
}
);
Ok(())
}
#[tokio::test]
async fn command_exec_env_overrides_merge_with_server_environment_and_support_unset() -> Result<()>
{
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[("COMMAND_EXEC_BASELINE", Some("server"))],
)
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
"/bin/sh".to_string(),
"-lc".to_string(),
"printf '%s|%s|%s|%s' \"$COMMAND_EXEC_BASELINE\" \"$COMMAND_EXEC_EXTRA\" \"${RUST_LOG-unset}\" \"$CODEX_HOME\"".to_string(),
],
process_id: None,
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: Some(HashMap::from([
(
"COMMAND_EXEC_BASELINE".to_string(),
Some("request".to_string()),
),
("COMMAND_EXEC_EXTRA".to_string(), Some("added".to_string())),
("RUST_LOG".to_string(), None),
])),
size: None,
sandbox_policy: None,
})
.await?;
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_eq!(
response,
CommandExecResponse {
exit_code: 0,
stdout: format!("request|added|unset|{}", codex_home.path().display()),
stderr: String::new(),
}
);
Ok(())
}
#[tokio::test]
async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()],
process_id: Some("invalid-timeout-1".to_string()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: true,
timeout_ms: Some(1_000),
cwd: None,
env: None,
size: None,
sandbox_policy: None,
})
.await?;
let error = mcp
.read_stream_until_error_message(RequestId::Integer(command_request_id))
.await?;
assert_eq!(
error.error.message,
"command/exec cannot set both timeoutMs and disableTimeout"
);
Ok(())
}
#[tokio::test]
async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()],
process_id: Some("invalid-cap-1".to_string()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: Some(1024),
disable_output_cap: true,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
})
.await?;
let error = mcp
.read_stream_until_error_message(RequestId::Integer(command_request_id))
.await?;
assert_eq!(
error.error.message,
"command/exec cannot set both outputBytesCap and disableOutputCap"
);
Ok(())
}
#[tokio::test]
async fn command_exec_rejects_negative_timeout_ms() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()],
process_id: Some("negative-timeout-1".to_string()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: Some(-1),
cwd: None,
env: None,
size: None,
sandbox_policy: None,
})
.await?;
let error = mcp
.read_stream_until_error_message(RequestId::Integer(command_request_id))
.await?;
assert_eq!(
error.error.message,
"command/exec timeoutMs must be non-negative, got -1"
);
Ok(())
}
#[tokio::test]
async fn command_exec_without_process_id_rejects_streaming() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec!["sh".to_string(), "-lc".to_string(), "cat".to_string()],
process_id: None,
tty: false,
stream_stdin: false,
stream_stdout_stderr: true,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
})
.await?;
let error = mcp
.read_stream_until_error_message(RequestId::Integer(command_request_id))
.await?;
assert_eq!(
error.error.message,
"command/exec tty or streaming requires a client-supplied processId"
);
Ok(())
}
#[tokio::test]
async fn command_exec_non_streaming_respects_output_cap() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
"sh".to_string(),
"-lc".to_string(),
"printf 'abcdef'; printf 'uvwxyz' >&2".to_string(),
],
process_id: Some("cap-1".to_string()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: Some(5),
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
})
.await?;
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_eq!(
response,
CommandExecResponse {
exit_code: 0,
stdout: "abcde".to_string(),
stderr: "uvwxy".to_string(),
}
);
Ok(())
}
#[tokio::test]
async fn command_exec_streaming_does_not_buffer_output() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "stream-cap-1".to_string();
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
"sh".to_string(),
"-lc".to_string(),
"printf 'abcdefghij'; sleep 30".to_string(),
],
process_id: Some(process_id.clone()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: true,
output_bytes_cap: Some(5),
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
})
.await?;
let delta = read_command_exec_delta(&mut mcp).await?;
assert_eq!(delta.process_id, process_id.as_str());
assert_eq!(delta.stream, CommandExecOutputStream::Stdout);
assert_eq!(STANDARD.decode(&delta.delta_base64)?, b"abcde");
assert!(delta.cap_reached);
let terminate_request_id = mcp
.send_command_exec_terminate_request(CommandExecTerminateParams {
process_id: process_id.clone(),
})
.await?;
let terminate_response = mcp
.read_stream_until_response_message(RequestId::Integer(terminate_request_id))
.await?;
assert_eq!(terminate_response.result, serde_json::json!({}));
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_ne!(
response.exit_code, 0,
"terminated command should not succeed"
);
assert_eq!(response.stdout, "");
assert_eq!(response.stderr, "");
Ok(())
}
#[tokio::test]
async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "pipe-1".to_string();
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
"sh".to_string(),
"-lc".to_string(),
"printf 'out-start\\n'; printf 'err-start\\n' >&2; IFS= read line; printf 'out:%s\\n' \"$line\"; printf 'err:%s\\n' \"$line\" >&2".to_string(),
],
process_id: Some(process_id.clone()),
tty: false,
stream_stdin: true,
stream_stdout_stderr: true,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
})
.await?;
let first_stdout = read_command_exec_delta(&mut mcp).await?;
let first_stderr = read_command_exec_delta(&mut mcp).await?;
let seen = [first_stdout, first_stderr];
assert!(
seen.iter()
.all(|delta| delta.process_id == process_id.as_str())
);
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stdout
&& delta.delta_base64 == STANDARD.encode("out-start\n")
}));
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stderr
&& delta.delta_base64 == STANDARD.encode("err-start\n")
}));
let write_request_id = mcp
.send_command_exec_write_request(CommandExecWriteParams {
process_id: process_id.clone(),
delta_base64: Some(STANDARD.encode("hello\n")),
close_stdin: true,
})
.await?;
let write_response = mcp
.read_stream_until_response_message(RequestId::Integer(write_request_id))
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let next_delta = read_command_exec_delta(&mut mcp).await?;
let final_delta = read_command_exec_delta(&mut mcp).await?;
let seen = [next_delta, final_delta];
assert!(
seen.iter()
.all(|delta| delta.process_id == process_id.as_str())
);
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stdout
&& delta.delta_base64 == STANDARD.encode("out:hello\n")
}));
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stderr
&& delta.delta_base64 == STANDARD.encode("err:hello\n")
}));
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_eq!(
response,
CommandExecResponse {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
}
);
Ok(())
}
#[tokio::test]
async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "tty-1".to_string();
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
"sh".to_string(),
"-lc".to_string(),
"stty -echo; if [ -t 0 ]; then printf 'tty\\n'; else printf 'notty\\n'; fi; IFS= read line; printf 'echo:%s\\n' \"$line\"".to_string(),
],
process_id: Some(process_id.clone()),
tty: true,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
})
.await?;
let started_text = read_command_exec_output_until_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"tty\n",
)
.await?;
assert!(
started_text.contains("tty\n"),
"expected TTY startup output, got {started_text:?}"
);
let write_request_id = mcp
.send_command_exec_write_request(CommandExecWriteParams {
process_id: process_id.clone(),
delta_base64: Some(STANDARD.encode("world\n")),
close_stdin: true,
})
.await?;
let write_response = mcp
.read_stream_until_response_message(RequestId::Integer(write_request_id))
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let echoed_text = read_command_exec_output_until_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"echo:world\n",
)
.await?;
assert!(
echoed_text.contains("echo:world\n"),
"expected TTY echo output, got {echoed_text:?}"
);
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_eq!(response.exit_code, 0);
assert_eq!(response.stdout, "");
assert_eq!(response.stderr, "");
Ok(())
}
#[tokio::test]
async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "tty-size-1".to_string();
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
"sh".to_string(),
"-lc".to_string(),
"stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\"".to_string(),
],
process_id: Some(process_id.clone()),
tty: true,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: Some(CommandExecTerminalSize {
rows: 31,
cols: 101,
}),
sandbox_policy: None,
})
.await?;
let started_text = read_command_exec_output_until_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"start:31 101\n",
)
.await?;
assert!(
started_text.contains("start:31 101\n"),
"unexpected initial size output: {started_text:?}"
);
let resize_request_id = mcp
.send_command_exec_resize_request(CommandExecResizeParams {
process_id: process_id.clone(),
size: CommandExecTerminalSize {
rows: 45,
cols: 132,
},
})
.await?;
let resize_response = mcp
.read_stream_until_response_message(RequestId::Integer(resize_request_id))
.await?;
assert_eq!(resize_response.result, serde_json::json!({}));
let write_request_id = mcp
.send_command_exec_write_request(CommandExecWriteParams {
process_id: process_id.clone(),
delta_base64: Some(STANDARD.encode("go\n")),
close_stdin: true,
})
.await?;
let write_response = mcp
.read_stream_until_response_message(RequestId::Integer(write_request_id))
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let resized_text = read_command_exec_output_until_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"after:45 132\n",
)
.await?;
assert!(
resized_text.contains("after:45 132\n"),
"unexpected resized output: {resized_text:?}"
);
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_eq!(response.exit_code, 0);
assert_eq!(response.stdout, "");
assert_eq!(response.stderr, "");
Ok(())
}
#[tokio::test]
async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminates_process()
-> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let bind_addr = reserve_local_addr()?;
let mut process = spawn_websocket_server(codex_home.path(), bind_addr).await?;
let mut ws1 = connect_websocket(bind_addr).await?;
let mut ws2 = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws1, 1, "ws_client_one").await?;
read_initialize_response(&mut ws1, 1).await?;
send_initialize_request(&mut ws2, 2, "ws_client_two").await?;
read_initialize_response(&mut ws2, 2).await?;
send_request(
&mut ws1,
"command/exec",
101,
Some(serde_json::json!({
"command": ["sh", "-lc", "printf 'ready\\n%s\\n' $$; sleep 30"],
"processId": "shared-process",
"streamStdoutStderr": true,
})),
)
.await?;
let delta = read_command_exec_delta_ws(&mut ws1).await?;
assert_eq!(delta.process_id, "shared-process");
assert_eq!(delta.stream, CommandExecOutputStream::Stdout);
let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?;
let pid = delta_text
.lines()
.last()
.context("delta should include shell pid")?
.parse::<u32>()
.context("parse shell pid")?;
send_request(
&mut ws2,
"command/exec/terminate",
102,
Some(serde_json::json!({
"processId": "shared-process",
})),
)
.await?;
let terminate_error = loop {
let message = read_jsonrpc_message(&mut ws2).await?;
if let JSONRPCMessage::Error(error) = message
&& error.id == RequestId::Integer(102)
{
break error;
}
};
assert_eq!(
terminate_error.error.message,
"no active command/exec for process id \"shared-process\""
);
assert!(process_is_alive(pid)?);
assert_no_message(&mut ws2, Duration::from_millis(250)).await?;
ws1.close(None).await?;
wait_for_process_exit(pid).await?;
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
Ok(())
}
async fn read_command_exec_delta(
mcp: &mut McpProcess,
) -> Result<CommandExecOutputDeltaNotification> {
let notification = mcp
.read_stream_until_notification_message("command/exec/outputDelta")
.await?;
decode_delta_notification(notification)
}
async fn read_command_exec_output_until_contains(
mcp: &mut McpProcess,
process_id: &str,
stream: CommandExecOutputStream,
expected: &str,
) -> Result<String> {
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
let mut collected = String::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let delta = timeout(remaining, read_command_exec_delta(mcp))
.await
.with_context(|| {
format!(
"timed out waiting for {expected:?} in command/exec output for {process_id}; collected {collected:?}"
)
})??;
assert_eq!(delta.process_id, process_id);
assert_eq!(delta.stream, stream);
let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?;
collected.push_str(&delta_text.replace('\r', ""));
if collected.contains(expected) {
return Ok(collected);
}
}
}
async fn read_command_exec_delta_ws(
stream: &mut super::connection_handling_websocket::WsClient,
) -> Result<CommandExecOutputDeltaNotification> {
loop {
let message = read_jsonrpc_message(stream).await?;
let JSONRPCMessage::Notification(notification) = message else {
continue;
};
if notification.method == "command/exec/outputDelta" {
return decode_delta_notification(notification);
}
}
}
fn decode_delta_notification(
notification: JSONRPCNotification,
) -> Result<CommandExecOutputDeltaNotification> {
let params = notification
.params
.context("command/exec/outputDelta notification should include params")?;
serde_json::from_value(params).context("deserialize command/exec/outputDelta notification")
}
async fn read_initialize_response(
stream: &mut super::connection_handling_websocket::WsClient,
request_id: i64,
) -> Result<()> {
loop {
let message = read_jsonrpc_message(stream).await?;
if let JSONRPCMessage::Response(response) = message
&& response.id == RequestId::Integer(request_id)
{
return Ok(());
}
}
}
async fn wait_for_process_exit(pid: u32) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if !process_is_alive(pid)? {
return Ok(());
}
if Instant::now() >= deadline {
anyhow::bail!("process {pid} was still alive after websocket disconnect");
}
sleep(Duration::from_millis(50)).await;
}
}
fn process_is_alive(pid: u32) -> Result<bool> {
let status = std::process::Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.status()
.context("spawn kill -0")?;
Ok(status.success())
}
@@ -23,6 +23,9 @@ use codex_app_server_protocol::ToolsV2;
use codex_app_server_protocol::WriteStatus;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchLocation;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -93,8 +96,11 @@ async fn config_read_includes_tools() -> Result<()> {
r#"
model = "gpt-user"
[tools.web_search]
context_size = "low"
allowed_domains = ["example.com"]
[tools]
web_search = true
view_image = false
"#,
)?;
@@ -125,12 +131,28 @@ view_image = false
assert_eq!(
tools,
ToolsV2 {
web_search: Some(true),
web_search: Some(WebSearchToolConfig {
context_size: Some(WebSearchContextSize::Low),
allowed_domains: Some(vec!["example.com".to_string()]),
location: None,
}),
view_image: Some(false),
}
);
assert_eq!(
origins.get("tools.web_search").expect("origin").name,
origins
.get("tools.web_search.context_size")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
}
);
assert_eq!(
origins
.get("tools.web_search.allowed_domains.0")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
}
@@ -148,6 +170,54 @@ view_image = false
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_nested_web_search_tool_config() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"
web_search = "live"
[tools.web_search]
context_size = "high"
allowed_domains = ["example.com"]
location = { country = "US", city = "New York", timezone = "America/New_York" }
"#,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ConfigReadResponse { config, .. } = to_response(resp)?;
assert_eq!(
config.tools.expect("tools present").web_search,
Some(WebSearchToolConfig {
context_size: Some(WebSearchContextSize::High),
allowed_domains: Some(vec!["example.com".to_string()]),
location: Some(WebSearchLocation {
country: Some("US".to_string()),
region: None,
city: Some("New York".to_string()),
timezone: Some("America/New_York".to_string()),
}),
}),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_apps() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -265,7 +265,7 @@ async fn read_error_for_id(stream: &mut WsClient, id: i64) -> Result<JSONRPCErro
}
}
async fn read_jsonrpc_message(stream: &mut WsClient) -> Result<JSONRPCMessage> {
pub(super) async fn read_jsonrpc_message(stream: &mut WsClient) -> Result<JSONRPCMessage> {
loop {
let frame = timeout(DEFAULT_READ_TIMEOUT, stream.next())
.await
@@ -2,6 +2,8 @@ mod account;
mod analytics;
mod app_list;
mod collaboration_mode_list;
#[cfg(unix)]
mod command_exec;
mod compaction;
mod config_rpc;
mod connection_handling_websocket;
@@ -449,7 +449,7 @@ fn write_plugin_source(
plugin_name: &str,
app_ids: &[&str],
) -> Result<()> {
let plugin_root = repo_root.join(".agents/plugins").join(plugin_name);
let plugin_root = repo_root.join(plugin_name);
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
@@ -125,11 +125,13 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
}
#[tokio::test]
async fn plugin_list_includes_enabled_state_from_config() -> Result<()> {
async fn plugin_list_includes_install_and_enabled_state_from_config() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
write_installed_plugin(&codex_home, "codex-curated", "enabled-plugin")?;
write_installed_plugin(&codex_home, "codex-curated", "disabled-plugin")?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
r#"{
@@ -148,6 +150,13 @@ async fn plugin_list_includes_enabled_state_from_config() -> Result<()> {
"source": "local",
"path": "./disabled-plugin"
}
},
{
"name": "uninstalled-plugin",
"source": {
"source": "local",
"path": "./uninstalled-plugin"
}
}
]
}"#,
@@ -185,16 +194,31 @@ enabled = false
.marketplaces
.into_iter()
.find(|marketplace| {
marketplace.path == repo_root.path().join(".agents/plugins/marketplace.json")
marketplace.path
== AbsolutePathBuf::try_from(
repo_root.path().join(".agents/plugins/marketplace.json"),
)
.expect("absolute marketplace path")
})
.expect("expected repo marketplace entry");
assert_eq!(marketplace.name, "codex-curated");
assert_eq!(marketplace.plugins.len(), 2);
assert_eq!(marketplace.plugins.len(), 3);
assert_eq!(marketplace.plugins[0].id, "enabled-plugin@codex-curated");
assert_eq!(marketplace.plugins[0].name, "enabled-plugin");
assert_eq!(marketplace.plugins[0].installed, true);
assert_eq!(marketplace.plugins[0].enabled, true);
assert_eq!(marketplace.plugins[1].id, "disabled-plugin@codex-curated");
assert_eq!(marketplace.plugins[1].name, "disabled-plugin");
assert_eq!(marketplace.plugins[1].installed, true);
assert_eq!(marketplace.plugins[1].enabled, false);
assert_eq!(
marketplace.plugins[2].id,
"uninstalled-plugin@codex-curated"
);
assert_eq!(marketplace.plugins[2].name, "uninstalled-plugin");
assert_eq!(marketplace.plugins[2].installed, false);
assert_eq!(marketplace.plugins[2].enabled, false);
Ok(())
}
@@ -202,6 +226,7 @@ enabled = false
async fn plugin_list_uses_home_config_for_enabled_state() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?;
write_installed_plugin(&codex_home, "codex-curated", "shared-plugin")?;
std::fs::write(
codex_home.path().join(".agents/plugins/marketplace.json"),
r#"{
@@ -294,6 +319,141 @@ enabled = false
.flat_map(|marketplace| marketplace.plugins.iter())
.find(|plugin| plugin.name == "shared-plugin")
.expect("expected shared-plugin entry");
assert_eq!(shared_plugin.id, "shared-plugin@codex-curated");
assert_eq!(shared_plugin.installed, true);
assert_eq!(shared_plugin.enabled, true);
Ok(())
}
#[tokio::test]
async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let plugin_root = repo_root.path().join("plugins/demo-plugin");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
}
]
}"#,
)?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r##"{
"name": "demo-plugin",
"interface": {
"displayName": "Plugin Display Name",
"shortDescription": "Short description for subtitle",
"longDescription": "Long description for details page",
"developerName": "OpenAI",
"category": "Productivity",
"capabilities": ["Interactive", "Write"],
"websiteURL": "https://openai.com/",
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
"defaultPrompt": "Starter prompt for trying a plugin",
"brandColor": "#3B82F6",
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
"screenshots": ["./assets/screenshot1.png", "./assets/screenshot2.png"]
}
}"##,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let plugin = response
.marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
.find(|plugin| plugin.name == "demo-plugin")
.expect("expected demo-plugin entry");
assert_eq!(plugin.id, "demo-plugin@codex-curated");
assert_eq!(plugin.installed, false);
assert_eq!(plugin.enabled, false);
let interface = plugin
.interface
.as_ref()
.expect("expected plugin interface");
assert_eq!(
interface.display_name.as_deref(),
Some("Plugin Display Name")
);
assert_eq!(
interface.website_url.as_deref(),
Some("https://openai.com/")
);
assert_eq!(
interface.privacy_policy_url.as_deref(),
Some("https://openai.com/policies/row-privacy-policy/")
);
assert_eq!(
interface.terms_of_service_url.as_deref(),
Some("https://openai.com/policies/row-terms-of-use/")
);
assert_eq!(
interface.composer_icon,
Some(AbsolutePathBuf::try_from(
plugin_root.join("assets/icon.png")
)?)
);
assert_eq!(
interface.logo,
Some(AbsolutePathBuf::try_from(
plugin_root.join("assets/logo.png")
)?)
);
assert_eq!(
interface.screenshots,
vec![
AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot1.png"))?,
AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot2.png"))?,
]
);
Ok(())
}
fn write_installed_plugin(
codex_home: &TempDir,
marketplace_name: &str,
plugin_name: &str,
) -> Result<()> {
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join(marketplace_name)
.join(plugin_name)
.join("local/.codex-plugin");
std::fs::create_dir_all(&plugin_root)?;
std::fs::write(
plugin_root.join("plugin.json"),
format!(r#"{{"name":"{plugin_name}"}}"#),
)?;
Ok(())
}
@@ -138,6 +138,9 @@ pub struct NetworkRequirementsToml {
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
pub allowed_domains: Option<Vec<String>>,
/// When true, only managed `allowed_domains` are respected while managed
/// network enforcement is active. User allowlist entries are ignored.
pub managed_allowed_domains_only: Option<bool>,
pub denied_domains: Option<Vec<String>>,
pub allow_unix_sockets: Option<Vec<String>>,
pub allow_local_binding: Option<bool>,
@@ -153,6 +156,9 @@ pub struct NetworkConstraints {
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
pub allowed_domains: Option<Vec<String>>,
/// When true, only managed `allowed_domains` are respected while managed
/// network enforcement is active. User allowlist entries are ignored.
pub managed_allowed_domains_only: Option<bool>,
pub denied_domains: Option<Vec<String>>,
pub allow_unix_sockets: Option<Vec<String>>,
pub allow_local_binding: Option<bool>,
@@ -168,6 +174,7 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
dangerously_allow_non_loopback_proxy,
dangerously_allow_all_unix_sockets,
allowed_domains,
managed_allowed_domains_only,
denied_domains,
allow_unix_sockets,
allow_local_binding,
@@ -180,6 +187,7 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
dangerously_allow_non_loopback_proxy,
dangerously_allow_all_unix_sockets,
allowed_domains,
managed_allowed_domains_only,
denied_domains,
allow_unix_sockets,
allow_local_binding,
@@ -1118,6 +1126,7 @@ mod tests {
allow_upstream_proxy = false
dangerously_allow_all_unix_sockets = true
allowed_domains = ["api.example.com", "*.openai.com"]
managed_allowed_domains_only = true
denied_domains = ["blocked.example.com"]
allow_unix_sockets = ["/tmp/example.sock"]
allow_local_binding = false
@@ -1146,6 +1155,10 @@ mod tests {
"*.openai.com".to_string()
])
);
assert_eq!(
sourced_network.value.managed_allowed_domains_only,
Some(true)
);
assert_eq!(
sourced_network.value.denied_domains.as_ref(),
Some(&vec!["blocked.example.com".to_string()])
+94 -13
View File
@@ -530,10 +530,10 @@
"service_tier": {
"$ref": "#/definitions/ServiceTier"
},
"tools_view_image": {
"type": "boolean"
"tools": {
"$ref": "#/definitions/ToolsToml"
},
"tools_web_search": {
"tools_view_image": {
"type": "boolean"
},
"web_search": {
@@ -568,6 +568,30 @@
},
"type": "object"
},
"FileSystemAccessMode": {
"enum": [
"none",
"read",
"write"
],
"type": "string"
},
"FilesystemPermissionToml": {
"anyOf": [
{
"$ref": "#/definitions/FileSystemAccessMode"
},
{
"additionalProperties": {
"$ref": "#/definitions/FileSystemAccessMode"
},
"type": "object"
}
]
},
"FilesystemPermissionsToml": {
"type": "object"
},
"ForcedLoginMethod": {
"enum": [
"chatgpt",
@@ -1089,20 +1113,21 @@
},
"type": "object"
},
"PermissionsToml": {
"PermissionProfileToml": {
"additionalProperties": false,
"properties": {
"filesystem": {
"$ref": "#/definitions/FilesystemPermissionsToml"
},
"network": {
"allOf": [
{
"$ref": "#/definitions/NetworkToml"
}
],
"description": "Network proxy settings from `[permissions.network]`. User config can enable the proxy; managed requirements may still constrain values."
"$ref": "#/definitions/NetworkToml"
}
},
"type": "object"
},
"PermissionsToml": {
"type": "object"
},
"Personality": {
"enum": [
"none",
@@ -1439,8 +1464,12 @@
"type": "boolean"
},
"web_search": {
"default": null,
"type": "boolean"
"allOf": [
{
"$ref": "#/definitions/WebSearchToolConfig"
}
],
"default": null
}
},
"type": "object"
@@ -1548,6 +1577,32 @@
],
"type": "string"
},
"WebSearchContextSize": {
"enum": [
"low",
"medium",
"high"
],
"type": "string"
},
"WebSearchLocation": {
"additionalProperties": false,
"properties": {
"city": {
"type": "string"
},
"country": {
"type": "string"
},
"region": {
"type": "string"
},
"timezone": {
"type": "string"
}
},
"type": "object"
},
"WebSearchMode": {
"enum": [
"disabled",
@@ -1556,6 +1611,24 @@
],
"type": "string"
},
"WebSearchToolConfig": {
"additionalProperties": false,
"properties": {
"allowed_domains": {
"items": {
"type": "string"
},
"type": "array"
},
"context_size": {
"$ref": "#/definitions/WebSearchContextSize"
},
"location": {
"$ref": "#/definitions/WebSearchLocation"
}
},
"type": "object"
},
"WindowsSandboxModeToml": {
"enum": [
"elevated",
@@ -1664,6 +1737,10 @@
"description": "Compact prompt used for history compaction.",
"type": "string"
},
"default_permissions": {
"description": "Default named permissions profile to apply from the `[permissions]` table.",
"type": "string"
},
"developer_instructions": {
"default": null,
"description": "Developer instructions inserted as a `developer` role message.",
@@ -1688,6 +1765,10 @@
"description": "Experimental / do not use. Selects the realtime websocket model/snapshot used for the `Op::RealtimeConversation` connection.",
"type": "string"
},
"experimental_realtime_ws_startup_context": {
"description": "Experimental / do not use. Replaces the synthesized realtime startup context appended to websocket session instructions. An empty string disables startup context injection entirely.",
"type": "string"
},
"experimental_use_freeform_apply_patch": {
"type": "boolean"
},
@@ -2067,7 +2148,7 @@
}
],
"default": null,
"description": "Nested permissions settings."
"description": "Named permissions profiles."
},
"personality": {
"allOf": [
+52 -48
View File
@@ -57,7 +57,7 @@ use codex_api::common::ResponsesWsRequest;
use codex_api::create_text_param_for_request;
use codex_api::error::ApiError;
use codex_api::requests::responses::Compression;
use codex_otel::OtelManager;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -281,14 +281,14 @@ impl ModelClient {
&self,
prompt: &Prompt,
model_info: &ModelInfo,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
) -> Result<Vec<ResponseItem>> {
if prompt.input.is_empty() {
return Ok(Vec::new());
}
let client_setup = self.current_client_setup().await?;
let transport = ReqwestTransport::new(build_reqwest_client());
let request_telemetry = Self::build_request_telemetry(otel_manager);
let request_telemetry = Self::build_request_telemetry(session_telemetry);
let client =
ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth)
.with_telemetry(Some(request_telemetry));
@@ -321,7 +321,7 @@ impl ModelClient {
raw_memories: Vec<ApiRawMemory>,
model_info: &ModelInfo,
effort: Option<ReasoningEffortConfig>,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
) -> Result<Vec<ApiMemorySummarizeOutput>> {
if raw_memories.is_empty() {
return Ok(Vec::new());
@@ -329,7 +329,7 @@ impl ModelClient {
let client_setup = self.current_client_setup().await?;
let transport = ReqwestTransport::new(build_reqwest_client());
let request_telemetry = Self::build_request_telemetry(otel_manager);
let request_telemetry = Self::build_request_telemetry(session_telemetry);
let client =
ApiMemoriesClient::new(transport, client_setup.api_provider, client_setup.api_auth)
.with_telemetry(Some(request_telemetry));
@@ -369,8 +369,8 @@ impl ModelClient {
}
/// Builds request telemetry for unary API calls (e.g., Compact endpoint).
fn build_request_telemetry(otel_manager: &OtelManager) -> Arc<dyn RequestTelemetry> {
let telemetry = Arc::new(ApiTelemetry::new(otel_manager.clone()));
fn build_request_telemetry(session_telemetry: &SessionTelemetry) -> Arc<dyn RequestTelemetry> {
let telemetry = Arc::new(ApiTelemetry::new(session_telemetry.clone()));
let request_telemetry: Arc<dyn RequestTelemetry> = telemetry;
request_telemetry
}
@@ -419,14 +419,14 @@ impl ModelClient {
/// behavior remains consistent across both flows.
async fn connect_websocket(
&self,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
api_provider: codex_api::Provider,
api_auth: CoreAuthProvider,
turn_state: Option<Arc<OnceLock<String>>>,
turn_metadata_header: Option<&str>,
) -> std::result::Result<ApiWebSocketConnection, ApiError> {
let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header);
let websocket_telemetry = ModelClientSession::build_websocket_telemetry(otel_manager);
let websocket_telemetry = ModelClientSession::build_websocket_telemetry(session_telemetry);
ApiWebSocketResponsesClient::new(api_provider, api_auth)
.connect(
headers,
@@ -660,7 +660,7 @@ impl ModelClientSession {
/// This performs only connection setup; it never sends prompt payloads.
pub async fn preconnect_websocket(
&mut self,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
model_info: &ModelInfo,
) -> std::result::Result<(), ApiError> {
if !self.client.responses_websocket_enabled(model_info) {
@@ -679,7 +679,7 @@ impl ModelClientSession {
let connection = self
.client
.connect_websocket(
otel_manager,
session_telemetry,
client_setup.api_provider,
client_setup.api_auth,
Some(Arc::clone(&self.turn_state)),
@@ -692,7 +692,7 @@ impl ModelClientSession {
/// Returns a websocket connection for this turn.
async fn websocket_connection(
&mut self,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
api_provider: codex_api::Provider,
api_auth: CoreAuthProvider,
turn_metadata_header: Option<&str>,
@@ -713,7 +713,7 @@ impl ModelClientSession {
let new_conn = self
.client
.connect_websocket(
otel_manager,
session_telemetry,
api_provider,
api_auth,
Some(turn_state),
@@ -751,7 +751,7 @@ impl ModelClientSession {
&self,
prompt: &Prompt,
model_info: &ModelInfo,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
@@ -764,7 +764,7 @@ impl ModelClientSession {
self.client.state.provider.stream_idle_timeout(),
)
.map_err(map_api_error)?;
let (stream, _last_request_rx) = map_response_stream(stream, otel_manager.clone());
let (stream, _last_request_rx) = map_response_stream(stream, session_telemetry.clone());
return Ok(stream);
}
@@ -775,7 +775,8 @@ impl ModelClientSession {
loop {
let client_setup = self.client.current_client_setup().await?;
let transport = ReqwestTransport::new(build_reqwest_client());
let (request_telemetry, sse_telemetry) = Self::build_streaming_telemetry(otel_manager);
let (request_telemetry, sse_telemetry) =
Self::build_streaming_telemetry(session_telemetry);
let compression = self.responses_request_compression(client_setup.auth.as_ref());
let options = self.build_responses_options(turn_metadata_header, compression);
@@ -797,7 +798,7 @@ impl ModelClientSession {
match stream_result {
Ok(stream) => {
let (stream, _) = map_response_stream(stream, otel_manager.clone());
let (stream, _) = map_response_stream(stream, session_telemetry.clone());
return Ok(stream);
}
Err(ApiError::Transport(
@@ -817,7 +818,7 @@ impl ModelClientSession {
&mut self,
prompt: &Prompt,
model_info: &ModelInfo,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
@@ -852,7 +853,7 @@ impl ModelClientSession {
match self
.websocket_connection(
otel_manager,
session_telemetry,
client_setup.api_provider,
client_setup.api_auth,
turn_metadata_header,
@@ -890,7 +891,7 @@ impl ModelClientSession {
.await
.map_err(map_api_error)?;
let (stream, last_request_rx) =
map_response_stream(stream_result, otel_manager.clone());
map_response_stream(stream_result, session_telemetry.clone());
self.websocket_session.last_response_rx = Some(last_request_rx);
return Ok(WebsocketStreamOutcome::Stream(stream));
}
@@ -898,17 +899,19 @@ impl ModelClientSession {
/// Builds request and SSE telemetry for streaming API calls.
fn build_streaming_telemetry(
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
) -> (Arc<dyn RequestTelemetry>, Arc<dyn SseTelemetry>) {
let telemetry = Arc::new(ApiTelemetry::new(otel_manager.clone()));
let telemetry = Arc::new(ApiTelemetry::new(session_telemetry.clone()));
let request_telemetry: Arc<dyn RequestTelemetry> = telemetry.clone();
let sse_telemetry: Arc<dyn SseTelemetry> = telemetry;
(request_telemetry, sse_telemetry)
}
/// Builds telemetry for the Responses API WebSocket transport.
fn build_websocket_telemetry(otel_manager: &OtelManager) -> Arc<dyn WebsocketTelemetry> {
let telemetry = Arc::new(ApiTelemetry::new(otel_manager.clone()));
fn build_websocket_telemetry(
session_telemetry: &SessionTelemetry,
) -> Arc<dyn WebsocketTelemetry> {
let telemetry = Arc::new(ApiTelemetry::new(session_telemetry.clone()));
let websocket_telemetry: Arc<dyn WebsocketTelemetry> = telemetry;
websocket_telemetry
}
@@ -918,7 +921,7 @@ impl ModelClientSession {
&mut self,
prompt: &Prompt,
model_info: &ModelInfo,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
@@ -935,7 +938,7 @@ impl ModelClientSession {
.stream_responses_websocket(
prompt,
model_info,
otel_manager,
session_telemetry,
effort,
summary,
service_tier,
@@ -956,7 +959,7 @@ impl ModelClientSession {
Ok(())
}
Ok(WebsocketStreamOutcome::FallbackToHttp) => {
self.try_switch_fallback_transport(otel_manager, model_info);
self.try_switch_fallback_transport(session_telemetry, model_info);
Ok(())
}
Err(err) => Err(err),
@@ -974,7 +977,7 @@ impl ModelClientSession {
&mut self,
prompt: &Prompt,
model_info: &ModelInfo,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
@@ -988,7 +991,7 @@ impl ModelClientSession {
.stream_responses_websocket(
prompt,
model_info,
otel_manager,
session_telemetry,
effort,
summary,
service_tier,
@@ -999,7 +1002,7 @@ impl ModelClientSession {
{
WebsocketStreamOutcome::Stream(stream) => return Ok(stream),
WebsocketStreamOutcome::FallbackToHttp => {
self.try_switch_fallback_transport(otel_manager, model_info);
self.try_switch_fallback_transport(session_telemetry, model_info);
}
}
}
@@ -1007,7 +1010,7 @@ impl ModelClientSession {
self.stream_responses_api(
prompt,
model_info,
otel_manager,
session_telemetry,
effort,
summary,
service_tier,
@@ -1026,14 +1029,14 @@ impl ModelClientSession {
/// Returns `true` if this call activated fallback, or `false` if fallback was already active.
pub(crate) fn try_switch_fallback_transport(
&mut self,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
model_info: &ModelInfo,
) -> bool {
let websocket_enabled = self.client.responses_websocket_enabled(model_info);
let activated = self.activate_http_fallback(websocket_enabled);
if activated {
warn!("falling back to HTTP");
otel_manager.counter(
session_telemetry.counter(
"codex.transport.fallback_to_http",
1,
&[("from_wire_api", "responses_websocket")],
@@ -1096,7 +1099,7 @@ fn build_responses_headers(
fn map_response_stream<S>(
api_stream: S,
otel_manager: OtelManager,
session_telemetry: SessionTelemetry,
) -> (ResponseStream, oneshot::Receiver<LastResponse>)
where
S: futures::Stream<Item = std::result::Result<ResponseEvent, ApiError>>
@@ -1129,7 +1132,7 @@ where
token_usage,
}) => {
if let Some(usage) = &token_usage {
otel_manager.sse_event_completed(
session_telemetry.sse_event_completed(
usage.input_tokens,
usage.output_tokens,
Some(usage.cached_input_tokens),
@@ -1162,7 +1165,7 @@ where
Err(err) => {
let mapped = map_api_error(err);
if !logged_error {
otel_manager.see_event_completed_failed(&mapped);
session_telemetry.see_event_completed_failed(&mapped);
logged_error = true;
}
if tx_event.send(Err(mapped)).await.is_err() {
@@ -1198,12 +1201,12 @@ async fn handle_unauthorized(
}
struct ApiTelemetry {
otel_manager: OtelManager,
session_telemetry: SessionTelemetry,
}
impl ApiTelemetry {
fn new(otel_manager: OtelManager) -> Self {
Self { otel_manager }
fn new(session_telemetry: SessionTelemetry) -> Self {
Self { session_telemetry }
}
}
@@ -1216,7 +1219,7 @@ impl RequestTelemetry for ApiTelemetry {
duration: Duration,
) {
let error_message = error.map(std::string::ToString::to_string);
self.otel_manager.record_api_request(
self.session_telemetry.record_api_request(
attempt,
status.map(|s| s.as_u16()),
error_message.as_deref(),
@@ -1234,14 +1237,14 @@ impl SseTelemetry for ApiTelemetry {
>,
duration: Duration,
) {
self.otel_manager.log_sse_event(result, duration);
self.session_telemetry.log_sse_event(result, duration);
}
}
impl WebsocketTelemetry for ApiTelemetry {
fn on_ws_request(&self, duration: Duration, error: Option<&ApiError>) {
let error_message = error.map(std::string::ToString::to_string);
self.otel_manager
self.session_telemetry
.record_websocket_request(duration, error_message.as_deref());
}
@@ -1250,14 +1253,15 @@ impl WebsocketTelemetry for ApiTelemetry {
result: &std::result::Result<Option<std::result::Result<Message, Error>>, ApiError>,
duration: Duration,
) {
self.otel_manager.record_websocket_event(result, duration);
self.session_telemetry
.record_websocket_event(result, duration);
}
}
#[cfg(test)]
mod tests {
use super::ModelClient;
use codex_otel::OtelManager;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SessionSource;
@@ -1313,8 +1317,8 @@ mod tests {
.expect("deserialize test model info")
}
fn test_otel_manager() -> OtelManager {
OtelManager::new(
fn test_session_telemetry() -> SessionTelemetry {
SessionTelemetry::new(
ThreadId::new(),
"gpt-test",
"gpt-test",
@@ -1344,10 +1348,10 @@ mod tests {
async fn summarize_memories_returns_empty_for_empty_input() {
let client = test_model_client(SessionSource::Cli);
let model_info = test_model_info();
let otel_manager = test_otel_manager();
let session_telemetry = test_session_telemetry();
let output = client
.summarize_memories(Vec::new(), &model_info, None, &otel_manager)
.summarize_memories(Vec::new(), &model_info, None, &session_telemetry)
.await
.expect("empty summarize request should succeed");
assert_eq!(output.len(), 0);
+77
View File
@@ -154,8 +154,13 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
pub(crate) mod tools {
use crate::tools::spec::JsonSchema;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation;
use codex_protocol::config_types::WebSearchUserLocationType;
use serde::Deserialize;
use serde::Serialize;
use serde::Serializer;
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
/// Responses API.
@@ -176,6 +181,18 @@ pub(crate) mod tools {
WebSearch {
#[serde(skip_serializing_if = "Option::is_none")]
external_web_access: Option<bool>,
#[serde(
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_web_search_filters"
)]
filters: Option<WebSearchFilters>,
#[serde(
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_web_search_user_location"
)]
user_location: Option<WebSearchUserLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
search_context_size: Option<WebSearchContextSize>,
#[serde(skip_serializing_if = "Option::is_none")]
search_content_types: Option<Vec<String>>,
},
@@ -195,6 +212,66 @@ pub(crate) mod tools {
}
}
fn serialize_web_search_filters<S>(
filters: &Option<WebSearchFilters>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match filters {
Some(filters) => {
#[derive(Serialize)]
struct SerializableWebSearchFilters<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
allowed_domains: Option<&'a Vec<String>>,
}
SerializableWebSearchFilters {
allowed_domains: filters.allowed_domains.as_ref(),
}
.serialize(serializer)
}
None => serializer.serialize_none(),
}
}
fn serialize_web_search_user_location<S>(
user_location: &Option<WebSearchUserLocation>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match user_location {
Some(user_location) => {
#[derive(Serialize)]
struct SerializableWebSearchUserLocation<'a> {
#[serde(rename = "type")]
r#type: WebSearchUserLocationType,
#[serde(skip_serializing_if = "Option::is_none")]
country: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
region: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
city: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
timezone: Option<&'a String>,
}
SerializableWebSearchUserLocation {
r#type: user_location.r#type,
country: user_location.country.as_ref(),
region: user_location.region.as_ref(),
city: user_location.city.as_ref(),
timezone: user_location.timezone.as_ref(),
}
.serialize(serializer)
}
None => serializer.serialize_none(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FreeformTool {
pub(crate) name: String,
+53 -30
View File
@@ -87,6 +87,8 @@ use codex_protocol::models::BaseInstructions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::format_allow_prefixes;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::HasLegacyEvent;
use codex_protocol::protocol::ItemCompletedEvent;
@@ -297,7 +299,7 @@ use crate::unified_exec::UnifiedExecProcessManager;
use crate::util::backoff;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_async_utils::OrCancelExt;
use codex_otel::OtelManager;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
@@ -488,6 +490,8 @@ impl Codex {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -664,7 +668,7 @@ pub(crate) struct TurnContext {
pub(crate) config: Arc<Config>,
pub(crate) auth_manager: Option<Arc<AuthManager>>,
pub(crate) model_info: ModelInfo,
pub(crate) otel_manager: OtelManager,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) provider: ModelProviderInfo,
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
pub(crate) reasoning_summary: ReasoningSummaryConfig,
@@ -683,6 +687,8 @@ pub(crate) struct TurnContext {
pub(crate) personality: Option<Personality>,
pub(crate) approval_policy: Constrained<AskForApproval>,
pub(crate) sandbox_policy: Constrained<SandboxPolicy>,
pub(crate) file_system_sandbox_policy: FileSystemSandboxPolicy,
pub(crate) network_sandbox_policy: NetworkSandboxPolicy,
pub(crate) network: Option<NetworkProxy>,
pub(crate) windows_sandbox_level: WindowsSandboxLevel,
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
@@ -744,6 +750,7 @@ impl TurnContext {
web_search_mode: self.tools_config.web_search_mode,
session_source: self.session_source.clone(),
})
.with_web_search_config(self.tools_config.web_search_config.clone())
.with_allow_login_shell(self.tools_config.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
@@ -754,8 +761,8 @@ impl TurnContext {
config: Arc::new(config),
auth_manager: self.auth_manager.clone(),
model_info: model_info.clone(),
otel_manager: self
.otel_manager
session_telemetry: self
.session_telemetry
.clone()
.with_model(model.as_str(), model_info.slug.as_str()),
provider: self.provider.clone(),
@@ -773,6 +780,8 @@ impl TurnContext {
personality: self.personality,
approval_policy: self.approval_policy.clone(),
sandbox_policy: self.sandbox_policy.clone(),
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
network_sandbox_policy: self.network_sandbox_policy,
network: self.network.clone(),
windows_sandbox_level: self.windows_sandbox_level,
shell_environment_policy: self.shell_environment_policy.clone(),
@@ -878,6 +887,8 @@ pub(crate) struct SessionConfiguration {
approval_policy: Constrained<AskForApproval>,
/// How to sandbox commands executed in the system
sandbox_policy: Constrained<SandboxPolicy>,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
windows_sandbox_level: WindowsSandboxLevel,
/// Working directory that should be treated as the *root* of the
@@ -944,6 +955,10 @@ impl SessionConfiguration {
}
if let Some(sandbox_policy) = updates.sandbox_policy.clone() {
next_configuration.sandbox_policy.set(sandbox_policy)?;
next_configuration.file_system_sandbox_policy =
FileSystemSandboxPolicy::from(next_configuration.sandbox_policy.get());
next_configuration.network_sandbox_policy =
NetworkSandboxPolicy::from(next_configuration.sandbox_policy.get());
}
if let Some(windows_sandbox_level) = updates.windows_sandbox_level {
next_configuration.windows_sandbox_level = windows_sandbox_level;
@@ -1089,7 +1104,7 @@ impl Session {
#[allow(clippy::too_many_arguments)]
fn make_turn_context(
auth_manager: Option<Arc<AuthManager>>,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
provider: ModelProviderInfo,
session_configuration: &SessionConfiguration,
per_turn_config: Config,
@@ -1103,14 +1118,14 @@ impl Session {
let reasoning_summary = session_configuration
.model_reasoning_summary
.unwrap_or(model_info.default_reasoning_summary);
let otel_manager = otel_manager.clone().with_model(
let session_telemetry = session_telemetry.clone().with_model(
session_configuration.collaboration_mode.model(),
model_info.slug.as_str(),
);
let session_source = session_configuration.session_source.clone();
let auth_manager_for_context = auth_manager;
let provider_for_context = provider;
let otel_manager_for_context = otel_manager;
let session_telemetry_for_context = session_telemetry;
let per_turn_config = Arc::new(per_turn_config);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
@@ -1119,6 +1134,7 @@ impl Session {
web_search_mode: Some(per_turn_config.web_search_mode.value()),
session_source: session_source.clone(),
})
.with_web_search_config(per_turn_config.web_search_config.clone())
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_agent_roles(per_turn_config.agent_roles.clone());
@@ -1140,7 +1156,7 @@ impl Session {
config: per_turn_config.clone(),
auth_manager: auth_manager_for_context,
model_info: model_info.clone(),
otel_manager: otel_manager_for_context,
session_telemetry: session_telemetry_for_context,
provider: provider_for_context,
reasoning_effort,
reasoning_summary,
@@ -1156,6 +1172,8 @@ impl Session {
personality: session_configuration.personality,
approval_policy: session_configuration.approval_policy.clone(),
sandbox_policy: session_configuration.sandbox_policy.clone(),
file_system_sandbox_policy: session_configuration.file_system_sandbox_policy.clone(),
network_sandbox_policy: session_configuration.network_sandbox_policy,
network,
windows_sandbox_level: session_configuration.windows_sandbox_level,
shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(),
@@ -1355,7 +1373,7 @@ impl Session {
let originator = crate::default_client::originator().value;
let terminal_type = terminal::user_agent();
let session_model = session_configuration.collaboration_mode.model().to_string();
let mut otel_manager = OtelManager::new(
let mut session_telemetry = SessionTelemetry::new(
conversation_id,
session_model.as_str(),
session_model.as_str(),
@@ -1368,7 +1386,7 @@ impl Session {
session_configuration.session_source.clone(),
);
if let Some(service_name) = session_configuration.metrics_service_name.as_deref() {
otel_manager = otel_manager.with_metrics_service_name(service_name);
session_telemetry = session_telemetry.with_metrics_service_name(service_name);
}
let network_proxy_audit_metadata = NetworkProxyAuditMetadata {
conversation_id: Some(conversation_id.to_string()),
@@ -1381,8 +1399,8 @@ impl Session {
model: Some(session_model.clone()),
slug: Some(session_model),
};
config.features.emit_metrics(&otel_manager);
otel_manager.counter(
config.features.emit_metrics(&session_telemetry);
session_telemetry.counter(
"codex.thread.started",
1,
&[(
@@ -1395,7 +1413,7 @@ impl Session {
)],
);
otel_manager.conversation_starts(
session_telemetry.conversation_starts(
config.model_provider.name.as_str(),
session_configuration.collaboration_mode.reasoning_effort(),
config
@@ -1438,7 +1456,7 @@ impl Session {
conversation_id,
session_configuration.cwd.clone(),
&mut default_shell,
otel_manager.clone(),
session_telemetry.clone(),
)
}
} else {
@@ -1533,7 +1551,7 @@ impl Session {
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
exec_policy,
auth_manager: Arc::clone(&auth_manager),
otel_manager,
session_telemetry,
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
execve_session_approvals: RwLock::new(HashMap::new()),
@@ -2055,7 +2073,7 @@ impl Session {
next_cwd.to_path_buf(),
self.services.user_shell.as_ref().clone(),
self.services.shell_snapshot_tx.clone(),
self.services.otel_manager.clone(),
self.services.session_telemetry.clone(),
);
}
@@ -2202,7 +2220,7 @@ impl Session {
);
let mut turn_context: TurnContext = Self::make_turn_context(
Some(Arc::clone(&self.services.auth_manager)),
&self.services.otel_manager,
&self.services.session_telemetry,
session_configuration.provider.clone(),
&session_configuration,
per_turn_config,
@@ -3024,7 +3042,7 @@ impl Session {
pub(crate) async fn record_model_warning(&self, message: impl Into<String>, ctx: &TurnContext) {
self.services
.otel_manager
.session_telemetry
.counter("codex.model_warning", 1, &[]);
let item = ResponseItem::Message {
id: None,
@@ -4178,7 +4196,7 @@ mod handlers {
};
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
.await;
current_context.otel_manager.user_prompt(&items);
current_context.session_telemetry.user_prompt(&items);
// Attempt to inject input into current task.
if let Err(SteerInputError::NoActiveTurn(items)) = sess.steer_input(items, None).await {
@@ -4814,7 +4832,7 @@ mod handlers {
.iter()
.filter(|item| is_user_turn_boundary(item))
.count();
sess.services.otel_manager.counter(
sess.services.session_telemetry.counter(
"codex.conversation.turn.count",
i64::try_from(turn_count).unwrap_or(0),
&[],
@@ -4911,6 +4929,7 @@ async fn spawn_review_thread(
web_search_mode: Some(review_web_search_mode),
session_source: parent_turn_context.session_source.clone(),
})
.with_web_search_config(None)
.with_allow_login_shell(config.permissions.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
@@ -4933,13 +4952,13 @@ async fn spawn_review_thread(
);
}
let otel_manager = parent_turn_context
.otel_manager
let session_telemetry = parent_turn_context
.session_telemetry
.clone()
.with_model(model.as_str(), review_model_info.slug.as_str());
let auth_manager_for_context = auth_manager.clone();
let provider_for_context = provider.clone();
let otel_manager_for_context = otel_manager.clone();
let session_telemetry_for_context = session_telemetry.clone();
let reasoning_effort = per_turn_config.model_reasoning_effort;
let reasoning_summary = per_turn_config
.model_reasoning_summary
@@ -4965,7 +4984,7 @@ async fn spawn_review_thread(
config: per_turn_config,
auth_manager: auth_manager_for_context,
model_info: model_info.clone(),
otel_manager: otel_manager_for_context,
session_telemetry: session_telemetry_for_context,
provider: provider_for_context,
reasoning_effort,
reasoning_summary,
@@ -4983,6 +5002,8 @@ async fn spawn_review_thread(
personality: parent_turn_context.personality,
approval_policy: parent_turn_context.approval_policy.clone(),
sandbox_policy: parent_turn_context.sandbox_policy.clone(),
file_system_sandbox_policy: parent_turn_context.file_system_sandbox_policy.clone(),
network_sandbox_policy: parent_turn_context.network_sandbox_policy,
network: parent_turn_context.network.clone(),
windows_sandbox_level: parent_turn_context.windows_sandbox_level,
shell_environment_policy: parent_turn_context.shell_environment_policy.clone(),
@@ -5194,7 +5215,7 @@ pub(crate) async fn run_turn(
)
.await;
let otel_manager = turn_context.otel_manager.clone();
let session_telemetry = turn_context.session_telemetry.clone();
let thread_id = sess.conversation_id.to_string();
let tracking = build_track_events_context(
turn_context.model_info.slug.clone(),
@@ -5206,7 +5227,7 @@ pub(crate) async fn run_turn(
warnings: skill_warnings,
} = build_skill_injections(
&mentioned_skills,
Some(&otel_manager),
Some(&session_telemetry),
&sess.services.analytics_events_client,
tracking.clone(),
)
@@ -5829,8 +5850,10 @@ async fn run_sampling_request(
// Use the configured provider-specific stream retry budget.
let max_retries = turn_context.provider.stream_max_retries();
if retries >= max_retries
&& client_session
.try_switch_fallback_transport(&turn_context.otel_manager, &turn_context.model_info)
&& client_session.try_switch_fallback_transport(
&turn_context.session_telemetry,
&turn_context.model_info,
)
{
sess.send_event(
&turn_context,
@@ -6533,7 +6556,7 @@ async fn try_run_sampling_request(
.stream(
prompt,
&turn_context.model_info,
&turn_context.otel_manager,
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
turn_context.config.service_tier,
@@ -6589,7 +6612,7 @@ async fn try_run_sampling_request(
};
sess.services
.otel_manager
.session_telemetry
.record_responses(&handle_responses, &event);
record_turn_ttft_metric(&turn_context, &event).await;
+27 -11
View File
@@ -1416,6 +1416,8 @@ async fn set_rate_limits_retains_previous_credits() {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -1510,6 +1512,8 @@ async fn set_rate_limits_updates_plan_type_when_present() {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -1811,13 +1815,13 @@ async fn build_test_config(codex_home: &Path) -> Config {
.expect("load default test config")
}
fn otel_manager(
fn session_telemetry(
conversation_id: ThreadId,
config: &Config,
model_info: &ModelInfo,
session_source: SessionSource,
) -> OtelManager {
OtelManager::new(
) -> SessionTelemetry {
SessionTelemetry::new(
conversation_id,
ModelsManager::get_model_offline_for_tests(config.model.as_deref()).as_str(),
model_info.slug.as_str(),
@@ -1862,6 +1866,8 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -1919,6 +1925,8 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -2009,6 +2017,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -2026,7 +2036,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
session_configuration.collaboration_mode.model(),
&per_turn_config,
);
let otel_manager = otel_manager(
let session_telemetry = session_telemetry(
conversation_id,
config.as_ref(),
&model_info,
@@ -2068,7 +2078,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
exec_policy,
auth_manager: auth_manager.clone(),
otel_manager: otel_manager.clone(),
session_telemetry: session_telemetry.clone(),
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
execve_session_approvals: RwLock::new(HashMap::new()),
@@ -2100,7 +2110,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
let turn_context = Session::make_turn_context(
Some(Arc::clone(&auth_manager)),
&otel_manager,
&session_telemetry,
session_configuration.provider.clone(),
&session_configuration,
per_turn_config,
@@ -2414,6 +2424,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -2431,7 +2443,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
session_configuration.collaboration_mode.model(),
&per_turn_config,
);
let otel_manager = otel_manager(
let session_telemetry = session_telemetry(
conversation_id,
config.as_ref(),
&model_info,
@@ -2473,7 +2485,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
exec_policy,
auth_manager: Arc::clone(&auth_manager),
otel_manager: otel_manager.clone(),
session_telemetry: session_telemetry.clone(),
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
execve_session_approvals: RwLock::new(HashMap::new()),
@@ -2505,7 +2517,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
let turn_context = Arc::new(Session::make_turn_context(
Some(Arc::clone(&auth_manager)),
&otel_manager,
&session_telemetry,
session_configuration.provider.clone(),
&session_configuration,
per_turn_config,
@@ -3841,11 +3853,15 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
// Now retry the same command WITHOUT escalated permissions; should succeed.
// Force DangerFullAccess to avoid platform sandbox dependencies in tests.
Arc::get_mut(&mut turn_context)
.expect("unique turn context Arc")
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique turn context Arc");
turn_context_mut
.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
turn_context_mut.file_system_sandbox_policy =
FileSystemSandboxPolicy::from(turn_context_mut.sandbox_policy.get());
turn_context_mut.network_sandbox_policy =
NetworkSandboxPolicy::from(turn_context_mut.sandbox_policy.get());
let resp2 = handler
.handle(ToolInvocation {
+1 -1
View File
@@ -400,7 +400,7 @@ async fn drain_to_completed(
.stream(
prompt,
&turn_context.model_info,
&turn_context.otel_manager,
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
turn_context.config.service_tier,
+1 -1
View File
@@ -107,7 +107,7 @@ async fn run_remote_compact_task_inner_impl(
.compact_conversation_history(
&prompt,
&turn_context.model_info,
&turn_context.otel_manager,
&turn_context.session_telemetry,
)
.or_else(|err| async {
let total_usage_breakdown = sess.get_total_token_usage_breakdown().await;
+478 -71
View File
@@ -13,6 +13,10 @@ use crate::config_loader::RequirementSource;
use crate::features::Feature;
use assert_matches::assert_matches;
use codex_config::CONFIG_TOML_FILE;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use serde::Deserialize;
use tempfile::tempdir;
@@ -197,9 +201,18 @@ fn runtime_config_defaults_model_availability_nux() {
}
#[test]
fn config_toml_deserializes_permissions_network() {
fn config_toml_deserializes_permission_profiles() {
let toml = r#"
[permissions.network]
default_permissions = "workspace"
[permissions.workspace.filesystem]
":minimal" = "read"
[permissions.workspace.filesystem.":project_roots"]
"." = "write"
"docs" = "read"
[permissions.workspace.network]
enabled = true
proxy_url = "http://127.0.0.1:43128"
enable_socks5 = false
@@ -207,55 +220,92 @@ allow_upstream_proxy = false
allowed_domains = ["openai.com"]
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for permissions.network");
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
assert_eq!(cfg.default_permissions.as_deref(), Some("workspace"));
assert_eq!(
cfg.permissions
.and_then(|permissions| permissions.network)
.expect("permissions.network should deserialize"),
NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
socks_url: None,
enable_socks5_udp: None,
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
mode: None,
allowed_domains: Some(vec!["openai.com".to_string()]),
denied_domains: None,
allow_unix_sockets: None,
allow_local_binding: None,
cfg.permissions.expect("[permissions] should deserialize"),
PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([
(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("docs".to_string(), FileSystemAccessMode::Read),
])),
),
]),
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
socks_url: None,
enable_socks5_udp: None,
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
mode: None,
allowed_domains: Some(vec!["openai.com".to_string()]),
denied_domains: None,
allow_unix_sockets: None,
allow_local_binding: None,
}),
},
)]),
}
);
}
#[test]
fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io::Result<()> {
fn permissions_profiles_network_populates_runtime_network_proxy_spec() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
permissions: Some(PermissionsToml {
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
..Default::default()
}),
}),
..Default::default()
};
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
let network = config
.permissions
.network
.as_ref()
.expect("enabled permissions.network should produce a NetworkProxySpec");
.expect("enabled profile network should produce a NetworkProxySpec");
assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128");
assert!(!network.socks_enabled());
@@ -263,24 +313,357 @@ fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io
}
#[test]
fn permissions_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> {
fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
permissions: Some(PermissionsToml {
network: Some(NetworkToml {
allowed_domains: Some(vec!["openai.com".to_string()]),
..Default::default()
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
allowed_domains: Some(vec!["openai.com".to_string()]),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
assert!(config.permissions.network.is_none());
Ok(())
}
#[test]
fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::create_dir_all(cwd.path().join("docs"))?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let cfg = ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([
(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("docs".to_string(), FileSystemAccessMode::Read),
])),
),
]),
}),
network: None,
},
)]),
}),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
assert!(config.permissions.network.is_none());
let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories")).unwrap();
assert_eq!(
config.permissions.file_system_sandbox_policy,
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(None),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some("docs".into())),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: memories_root.clone(),
},
access: FileSystemAccessMode::Write,
},
]),
);
assert_eq!(
config.permissions.sandbox_policy.get(),
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![memories_root],
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![
AbsolutePathBuf::try_from(cwd.path().join("docs")).expect("absolute docs path"),
],
},
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
);
assert_eq!(
config.permissions.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
Ok(())
}
#[test]
fn permissions_profiles_require_default_permissions() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("missing default_permissions should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"config defines `[permissions]` profiles but does not set `default_permissions`"
);
Ok(())
}
#[test]
fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let external_write_path = if cfg!(windows) { r"C:\temp" } else { "/tmp" };
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
external_write_path.to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("writes outside the workspace root should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
err.to_string()
.contains("filesystem writes outside the workspace root"),
"{err}"
);
Ok(())
}
#[test]
fn permissions_profiles_reject_nested_entries_for_non_project_roots() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
"docs".to_string(),
FileSystemAccessMode::Read,
)])),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("nested entries outside :project_roots should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"filesystem path `:minimal` does not support nested entries"
);
Ok(())
}
#[test]
fn permissions_profiles_reject_project_root_parent_traversal() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
"../sibling".to_string(),
FileSystemAccessMode::Read,
)])),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("parent traversal should be rejected for project root subpaths");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"filesystem subpath `../sibling` must be a descendant path without `.` or `..` components"
);
Ok(())
}
#[test]
fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(NetworkToml {
enabled: Some(true),
..Default::default()
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
assert!(
config.permissions.network_sandbox_policy.is_enabled(),
"expected network sandbox policy to be enabled",
);
assert!(
config
.permissions
.sandbox_policy
.get()
.has_full_network_access()
);
Ok(())
}
@@ -867,34 +1250,6 @@ fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Resu
Ok(())
}
#[test]
fn profile_legacy_toggles_override_base() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut profiles = HashMap::new();
profiles.insert(
"work".to_string(),
ConfigProfile {
tools_web_search: Some(false),
..Default::default()
},
);
let cfg = ConfigToml {
profiles,
profile: Some("work".to_string()),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert!(!config.features.enabled(Feature::WebSearchRequest));
Ok(())
}
#[tokio::test]
async fn project_profile_overrides_user_profile() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -2653,6 +3008,10 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -2704,6 +3063,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
experimental_realtime_ws_backend_prompt: None,
experimental_realtime_ws_startup_context: None,
base_instructions: None,
developer_instructions: None,
compact_prompt: None,
@@ -2712,6 +3072,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
@@ -2782,6 +3143,10 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -2833,6 +3198,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
experimental_realtime_ws_backend_prompt: None,
experimental_realtime_ws_startup_context: None,
base_instructions: None,
developer_instructions: None,
compact_prompt: None,
@@ -2841,6 +3207,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
@@ -2909,6 +3276,10 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -2960,6 +3331,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
experimental_realtime_ws_backend_prompt: None,
experimental_realtime_ws_startup_context: None,
base_instructions: None,
developer_instructions: None,
compact_prompt: None,
@@ -2968,6 +3340,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
@@ -3022,6 +3395,10 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -3073,6 +3450,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
experimental_realtime_ws_backend_prompt: None,
experimental_realtime_ws_startup_context: None,
base_instructions: None,
developer_instructions: None,
compact_prompt: None,
@@ -3081,6 +3459,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
@@ -4033,6 +4412,34 @@ experimental_realtime_ws_backend_prompt = "prompt from config"
Ok(())
}
#[test]
fn experimental_realtime_ws_startup_context_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
experimental_realtime_ws_startup_context = "startup context from config"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_realtime_ws_startup_context.as_deref(),
Some("startup context from config")
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert_eq!(
config.experimental_realtime_ws_startup_context.as_deref(),
Some("startup context from config")
);
Ok(())
}
#[test]
fn experimental_realtime_ws_model_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
@@ -218,14 +218,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature
enabled,
));
}
if let Some(enabled) = cfg.tools.as_ref().and_then(|tools| tools.web_search) {
explicit_settings.push((
"tools.web_search".to_string(),
Feature::WebSearchRequest,
enabled,
));
}
for (profile_name, profile) in &cfg.profiles {
if let Some(features) = profile.features.as_ref() {
for (key, enabled) in &features.entries {
@@ -259,13 +251,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature
enabled,
));
}
if let Some(enabled) = profile.tools_web_search {
explicit_settings.push((
format!("profiles.{profile_name}.tools_web_search"),
Feature::WebSearchRequest,
enabled,
));
}
}
explicit_settings
+256 -33
View File
@@ -27,6 +27,7 @@ use crate::config::types::WindowsSandboxModeToml;
use crate::config::types::WindowsToml;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConstrainedWithSource;
use crate::config_loader::LoaderOverrides;
@@ -67,11 +68,15 @@ use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchConfig;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
@@ -86,8 +91,10 @@ use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use crate::config::permissions::network_proxy_config_from_permissions;
use crate::config::permissions::compile_permission_profile;
use crate::config::permissions::network_proxy_config_from_profile_network;
use crate::config::profile::ConfigProfile;
use codex_network_proxy::NetworkProxyConfig;
use toml::Value as TomlValue;
use toml_edit::DocumentMut;
@@ -107,8 +114,12 @@ pub use codex_network_proxy::NetworkProxyAuditMetadata;
pub use managed_features::ManagedFeatures;
pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub use permissions::FilesystemPermissionToml;
pub use permissions::FilesystemPermissionsToml;
pub use permissions::NetworkToml;
pub use permissions::PermissionProfileToml;
pub use permissions::PermissionsToml;
pub(crate) use permissions::resolve_permission_profile;
pub use service::ConfigService;
pub use service::ConfigServiceError;
@@ -137,11 +148,9 @@ fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
Some(resolved_cwd.join(path))
}
}
#[cfg(test)]
pub(crate) fn test_config() -> Config {
use tempfile::tempdir;
let codex_home = tempdir().expect("create temp dir");
let codex_home = tempfile::tempdir().expect("create temp dir");
Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
@@ -157,6 +166,12 @@ pub struct Permissions {
pub approval_policy: Constrained<AskForApproval>,
/// Effective sandbox policy used for shell/unified exec.
pub sandbox_policy: Constrained<SandboxPolicy>,
/// Effective filesystem sandbox policy, including entries that cannot yet
/// be fully represented by the legacy [`SandboxPolicy`] projection.
pub file_system_sandbox_policy: FileSystemSandboxPolicy,
/// Effective network sandbox policy split out from the legacy
/// [`SandboxPolicy`] projection.
pub network_sandbox_policy: NetworkSandboxPolicy,
/// Effective network configuration applied to all spawned processes.
pub network: Option<NetworkProxySpec>,
/// Whether the model may request a login shell for shell-based tools.
@@ -451,6 +466,10 @@ pub struct Config {
/// websocket transport instructions (the `Op::RealtimeConversation`
/// `/ws` session.update instructions) without changing normal prompts.
pub experimental_realtime_ws_backend_prompt: Option<String>,
/// Experimental / do not use. Replaces the synthesized realtime startup
/// context appended to websocket session instructions. An empty string
/// disables startup context injection entirely.
pub experimental_realtime_ws_startup_context: Option<String>,
/// When set, restricts ChatGPT login to a specific workspace identifier.
pub forced_chatgpt_workspace_id: Option<String>,
@@ -465,6 +484,9 @@ pub struct Config {
/// Explicit or feature-derived web search mode.
pub web_search_mode: Constrained<WebSearchMode>,
/// Additional parameters for the web search tool when it is enabled.
pub web_search_config: Option<WebSearchConfig>,
/// If set to `true`, used only the experimental unified exec tool.
pub use_experimental_unified_exec_tool: bool,
@@ -1045,7 +1067,11 @@ pub struct ConfigToml {
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
/// Nested permissions settings.
/// Default named permissions profile to apply from the `[permissions]`
/// table.
pub default_permissions: Option<String>,
/// Named permissions profiles.
#[serde(default)]
pub permissions: Option<PermissionsToml>,
@@ -1211,6 +1237,10 @@ pub struct ConfigToml {
/// websocket transport instructions (the `Op::RealtimeConversation`
/// `/ws` session.update instructions) without changing normal prompts.
pub experimental_realtime_ws_backend_prompt: Option<String>,
/// Experimental / do not use. Replaces the synthesized realtime startup
/// context appended to websocket session instructions. An empty string
/// disables startup context injection entirely.
pub experimental_realtime_ws_startup_context: Option<String>,
pub projects: Option<HashMap<String, ProjectConfig>>,
/// Controls the web search tool mode: disabled, cached, or live.
@@ -1354,8 +1384,8 @@ pub struct RealtimeAudioToml {
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ToolsToml {
#[serde(default, alias = "web_search_request")]
pub web_search: Option<bool>,
#[serde(default)]
pub web_search: Option<WebSearchToolConfig>,
/// Enable the `view_image` tool that lets the agent attach local images.
#[serde(default)]
@@ -1417,7 +1447,7 @@ pub struct AgentRoleToml {
impl From<ToolsToml> for Tools {
fn from(tools_toml: ToolsToml) -> Self {
Self {
web_search: tools_toml.web_search,
web_search: tools_toml.web_search.is_some().then_some(true),
view_image: tools_toml.view_image,
}
}
@@ -1563,6 +1593,78 @@ impl ConfigToml {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionConfigSyntax {
Legacy,
Profiles,
}
#[derive(Debug, Deserialize, Default)]
struct PermissionSelectionToml {
default_permissions: Option<String>,
sandbox_mode: Option<SandboxMode>,
}
fn resolve_permission_config_syntax(
config_layer_stack: &ConfigLayerStack,
cfg: &ConfigToml,
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
) -> Option<PermissionConfigSyntax> {
if sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() {
return Some(PermissionConfigSyntax::Legacy);
}
let mut selection = None;
for layer in
config_layer_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false)
{
let Ok(layer_selection) = layer.config.clone().try_into::<PermissionSelectionToml>() else {
continue;
};
if layer_selection.sandbox_mode.is_some() {
selection = Some(PermissionConfigSyntax::Legacy);
}
if layer_selection.default_permissions.is_some() {
selection = Some(PermissionConfigSyntax::Profiles);
}
}
selection.or_else(|| {
if cfg.default_permissions.is_some() {
Some(PermissionConfigSyntax::Profiles)
} else if cfg.sandbox_mode.is_some() {
Some(PermissionConfigSyntax::Legacy)
} else {
None
}
})
}
fn add_additional_file_system_writes(
file_system_sandbox_policy: &mut FileSystemSandboxPolicy,
additional_writable_roots: &[AbsolutePathBuf],
) {
for path in additional_writable_roots {
let exists = file_system_sandbox_policy.entries.iter().any(|entry| {
matches!(
&entry.path,
codex_protocol::permissions::FileSystemPath::Path { path: existing }
if existing == path && entry.access == codex_protocol::permissions::FileSystemAccessMode::Write
)
});
if !exists {
file_system_sandbox_policy.entries.push(
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Path { path: path.clone() },
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
);
}
}
}
/// Optional overrides for user configuration (e.g., from CLI flags).
#[derive(Default, Debug, Clone)]
pub struct ConfigOverrides {
@@ -1637,6 +1739,27 @@ fn resolve_web_search_mode(
None
}
fn resolve_web_search_config(
config_toml: &ConfigToml,
config_profile: &ConfigProfile,
) -> Option<WebSearchConfig> {
let base = config_toml
.tools
.as_ref()
.and_then(|tools| tools.web_search.as_ref());
let profile = config_profile
.tools
.as_ref()
.and_then(|tools| tools.web_search.as_ref());
match (base, profile) {
(None, None) => None,
(Some(base), None) => Some(base.clone().into()),
(None, Some(profile)) => Some(profile.clone().into()),
(Some(base), Some(profile)) => Some(base.merge(profile).into()),
}
}
pub(crate) fn resolve_web_search_mode_for_turn(
web_search_mode: &Constrained<WebSearchMode>,
sandbox_policy: &SandboxPolicy,
@@ -1750,9 +1873,6 @@ impl Config {
.clone(),
None => ConfigProfile::default(),
};
let configured_network_proxy_config =
network_proxy_config_from_permissions(cfg.permissions.as_ref());
let feature_overrides = FeatureOverrides {
include_apply_patch_tool: include_apply_patch_tool_override,
web_search_request: override_tools_web_search_request,
@@ -1779,42 +1899,123 @@ impl Config {
}
}
});
let additional_writable_roots: Vec<AbsolutePathBuf> = additional_writable_roots
let mut additional_writable_roots: Vec<AbsolutePathBuf> = additional_writable_roots
.into_iter()
.map(|path| AbsolutePathBuf::resolve_path_against_base(path, &resolved_cwd))
.collect::<Result<Vec<_>, _>>()?;
let active_project = cfg
.get_active_project(&resolved_cwd)
.unwrap_or(ProjectConfig { trust_level: None });
let permission_config_syntax = resolve_permission_config_syntax(
&config_layer_stack,
&cfg,
sandbox_mode,
config_profile.sandbox_mode,
);
let has_permission_profiles = cfg
.permissions
.as_ref()
.is_some_and(|profiles| !profiles.is_empty());
if has_permission_profiles
&& !matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Legacy)
)
&& cfg.default_permissions.is_none()
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"config defines `[permissions]` profiles but does not set `default_permissions`",
));
}
let windows_sandbox_level = match windows_sandbox_mode {
Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated,
Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken,
None => WindowsSandboxLevel::from_features(&features),
};
let mut sandbox_policy = cfg.derive_sandbox_policy(
sandbox_mode,
config_profile.sandbox_mode,
windows_sandbox_level,
&resolved_cwd,
Some(&constrained_sandbox_policy),
);
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
let memories_root = memory_root(&codex_home);
std::fs::create_dir_all(&memories_root)?;
let memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?;
if !writable_roots
.iter()
.any(|existing| existing == &memories_root)
{
writable_roots.push(memories_root);
let memories_root = memory_root(&codex_home);
std::fs::create_dir_all(&memories_root)?;
let memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?;
if !additional_writable_roots
.iter()
.any(|existing| existing == &memories_root)
{
additional_writable_roots.push(memories_root);
}
let profiles_are_active = matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
) || (permission_config_syntax.is_none()
&& has_permission_profiles);
let (
configured_network_proxy_config,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
) = if profiles_are_active {
let permissions = cfg.permissions.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"default_permissions requires a `[permissions]` table",
)
})?;
let default_permissions = cfg.default_permissions.as_deref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"default_permissions requires a named permissions profile",
)
})?;
let profile = resolve_permission_profile(permissions, default_permissions)?;
let configured_network_proxy_config =
network_proxy_config_from_profile_network(profile.network.as_ref());
let (mut file_system_sandbox_policy, network_sandbox_policy) =
compile_permission_profile(permissions, default_permissions)?;
let mut sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
add_additional_file_system_writes(
&mut file_system_sandbox_policy,
&additional_writable_roots,
);
sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
}
for path in additional_writable_roots {
if !writable_roots.iter().any(|existing| existing == &path) {
writable_roots.push(path);
(
configured_network_proxy_config,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
)
} else {
let configured_network_proxy_config = NetworkProxyConfig::default();
let mut sandbox_policy = cfg.derive_sandbox_policy(
sandbox_mode,
config_profile.sandbox_mode,
windows_sandbox_level,
&resolved_cwd,
Some(&constrained_sandbox_policy),
);
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
for path in &additional_writable_roots {
if !writable_roots.iter().any(|existing| existing == path) {
writable_roots.push(path.clone());
}
}
}
}
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
(
configured_network_proxy_config,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
)
};
let approval_policy_was_explicit = approval_policy_override.is_some()
|| config_profile.approval_policy.is_some()
|| cfg.approval_policy.is_some();
let mut approval_policy = approval_policy_override
.or(config_profile.approval_policy)
.or(cfg.approval_policy)
@@ -1827,7 +2028,9 @@ impl Config {
AskForApproval::default()
}
});
if let Err(err) = constrained_approval_policy.can_set(&approval_policy) {
if !approval_policy_was_explicit
&& let Err(err) = constrained_approval_policy.can_set(&approval_policy)
{
tracing::warn!(
error = %err,
"default approval policy is disallowed by requirements; falling back to required default"
@@ -1836,6 +2039,7 @@ impl Config {
}
let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features)
.unwrap_or(WebSearchMode::Cached);
let web_search_config = resolve_web_search_config(&cfg, &config_profile);
let mut model_providers = built_in_model_providers();
// Merge user-defined providers into the built-in list.
@@ -2072,6 +2276,7 @@ impl Config {
.map(AbsolutePathBuf::to_path_buf)
.or_else(|| resolve_sqlite_home_env(&resolved_cwd))
.unwrap_or_else(|| codex_home.to_path_buf());
let original_sandbox_policy = sandbox_policy.clone();
apply_requirement_constrained_value(
"approval_policy",
@@ -2103,6 +2308,7 @@ impl Config {
let network = NetworkProxySpec::from_config_and_constraints(
configured_network_proxy_config,
network_requirements,
constrained_sandbox_policy.get(),
)
.map_err(|err| {
if let Some(source) = network_requirements_source.as_ref() {
@@ -2119,6 +2325,19 @@ impl Config {
} else {
network.enabled().then_some(network)
};
let effective_sandbox_policy = constrained_sandbox_policy.value.get().clone();
let effective_file_system_sandbox_policy =
if effective_sandbox_policy == original_sandbox_policy {
file_system_sandbox_policy
} else {
FileSystemSandboxPolicy::from(&effective_sandbox_policy)
};
let effective_network_sandbox_policy =
if effective_sandbox_policy == original_sandbox_policy {
network_sandbox_policy
} else {
NetworkSandboxPolicy::from(&effective_sandbox_policy)
};
let config = Self {
model,
@@ -2133,6 +2352,8 @@ impl Config {
permissions: Permissions {
approval_policy: constrained_approval_policy.value,
sandbox_policy: constrained_sandbox_policy.value,
file_system_sandbox_policy: effective_file_system_sandbox_policy,
network_sandbox_policy: effective_network_sandbox_policy,
network,
allow_login_shell,
shell_environment_policy,
@@ -2220,10 +2441,12 @@ impl Config {
experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url,
experimental_realtime_ws_model: cfg.experimental_realtime_ws_model,
experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt,
experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context,
forced_chatgpt_workspace_id,
forced_login_method,
include_apply_patch_tool: include_apply_patch_tool_flag,
web_search_mode: constrained_web_search_mode.value,
web_search_config,
use_experimental_unified_exec_tool,
background_terminal_max_timeout,
ghost_snapshot,
+253 -4
View File
@@ -21,6 +21,7 @@ use std::sync::Arc;
pub struct NetworkProxySpec {
config: NetworkProxyConfig,
constraints: NetworkProxyConstraints,
hard_deny_allowlist_misses: bool,
}
pub struct StartedNetworkProxy {
@@ -83,9 +84,18 @@ impl NetworkProxySpec {
pub(crate) fn from_config_and_constraints(
config: NetworkProxyConfig,
requirements: Option<NetworkConstraints>,
sandbox_policy: &SandboxPolicy,
) -> std::io::Result<Self> {
let hard_deny_allowlist_misses = requirements
.as_ref()
.is_some_and(Self::managed_allowed_domains_only);
let (config, constraints) = if let Some(requirements) = requirements {
Self::apply_requirements(config, &requirements)
Self::apply_requirements(
config,
&requirements,
sandbox_policy,
hard_deny_allowlist_misses,
)
} else {
(config, NetworkProxyConstraints::default())
};
@@ -98,6 +108,7 @@ impl NetworkProxySpec {
Ok(Self {
config,
constraints,
hard_deny_allowlist_misses,
})
}
@@ -112,6 +123,7 @@ impl NetworkProxySpec {
let state = self.build_state_with_audit_metadata(audit_metadata)?;
let mut builder = NetworkProxy::builder().state(Arc::new(state));
if enable_network_approval_flow
&& !self.hard_deny_allowlist_misses
&& matches!(
sandbox_policy,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
@@ -158,8 +170,13 @@ impl NetworkProxySpec {
fn apply_requirements(
mut config: NetworkProxyConfig,
requirements: &NetworkConstraints,
sandbox_policy: &SandboxPolicy,
hard_deny_allowlist_misses: bool,
) -> (NetworkProxyConfig, NetworkProxyConstraints) {
let mut constraints = NetworkProxyConstraints::default();
let allowlist_expansion_enabled =
Self::allowlist_expansion_enabled(sandbox_policy, hard_deny_allowlist_misses);
let denylist_expansion_enabled = Self::denylist_expansion_enabled(sandbox_policy);
if let Some(enabled) = requirements.enabled {
config.network.enabled = enabled;
@@ -190,13 +207,31 @@ impl NetworkProxySpec {
constraints.dangerously_allow_all_unix_sockets =
Some(dangerously_allow_all_unix_sockets);
}
if let Some(allowed_domains) = requirements.allowed_domains.clone() {
config.network.allowed_domains = allowed_domains.clone();
let managed_allowed_domains = if hard_deny_allowlist_misses {
Some(requirements.allowed_domains.clone().unwrap_or_default())
} else {
requirements.allowed_domains.clone()
};
if let Some(allowed_domains) = managed_allowed_domains {
// Managed requirements seed the baseline allowlist. User additions
// can extend that baseline unless managed-only mode pins the
// effective allowlist to the managed set.
config.network.allowed_domains = if allowlist_expansion_enabled {
Self::merge_domain_lists(allowed_domains.clone(), &config.network.allowed_domains)
} else {
allowed_domains.clone()
};
constraints.allowed_domains = Some(allowed_domains);
constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled);
}
if let Some(denied_domains) = requirements.denied_domains.clone() {
config.network.denied_domains = denied_domains.clone();
config.network.denied_domains = if denylist_expansion_enabled {
Self::merge_domain_lists(denied_domains.clone(), &config.network.denied_domains)
} else {
denied_domains.clone()
};
constraints.denied_domains = Some(denied_domains);
constraints.denylist_expansion_enabled = Some(denylist_expansion_enabled);
}
if let Some(allow_unix_sockets) = requirements.allow_unix_sockets.clone() {
config.network.allow_unix_sockets = allow_unix_sockets.clone();
@@ -209,6 +244,39 @@ impl NetworkProxySpec {
(config, constraints)
}
fn allowlist_expansion_enabled(
sandbox_policy: &SandboxPolicy,
hard_deny_allowlist_misses: bool,
) -> bool {
matches!(
sandbox_policy,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
) && !hard_deny_allowlist_misses
}
fn managed_allowed_domains_only(requirements: &NetworkConstraints) -> bool {
requirements.managed_allowed_domains_only.unwrap_or(false)
}
fn denylist_expansion_enabled(sandbox_policy: &SandboxPolicy) -> bool {
matches!(
sandbox_policy,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
)
}
fn merge_domain_lists(mut managed: Vec<String>, user_entries: &[String]) -> Vec<String> {
for entry in user_entries {
if !managed
.iter()
.any(|managed_entry| managed_entry.eq_ignore_ascii_case(entry))
{
managed.push(entry.clone());
}
}
managed
}
}
#[cfg(test)]
@@ -221,6 +289,7 @@ mod tests {
let spec = NetworkProxySpec {
config: NetworkProxyConfig::default(),
constraints: NetworkProxyConstraints::default(),
hard_deny_allowlist_misses: false,
};
let metadata = NetworkProxyAuditMetadata {
conversation_id: Some("conversation-1".to_string()),
@@ -234,4 +303,184 @@ mod tests {
.expect("state should build");
assert_eq!(state.audit_metadata(), &metadata);
}
#[test]
fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["*.example.com".to_string()]),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_read_only_policy(),
)
.expect("config should stay within the managed allowlist");
assert_eq!(
spec.config.network.allowed_domains,
vec!["*.example.com".to_string(), "api.example.com".to_string()]
);
assert_eq!(
spec.constraints.allowed_domains,
Some(vec!["*.example.com".to_string()])
);
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true));
}
#[test]
fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["evil.com".to_string()];
config.network.denied_domains = vec!["more-blocked.example.com".to_string()];
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["*.example.com".to_string()]),
denied_domains: Some(vec!["blocked.example.com".to_string()]),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::DangerFullAccess,
)
.expect("yolo mode should pin the effective policy to the managed baseline");
assert_eq!(
spec.config.network.allowed_domains,
vec!["*.example.com".to_string()]
);
assert_eq!(
spec.config.network.denied_domains,
vec!["blocked.example.com".to_string()]
);
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false));
}
#[test]
fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["*.example.com".to_string()]),
managed_allowed_domains_only: Some(true),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
)
.expect("managed baseline should still load");
assert_eq!(
spec.config.network.allowed_domains,
vec!["*.example.com".to_string()]
);
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
}
#[test]
fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["managed.example.com".to_string()]),
managed_allowed_domains_only: Some(true),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
)
.expect("managed-only allowlist should still load");
assert_eq!(
spec.config.network.allowed_domains,
vec!["managed.example.com".to_string()]
);
assert_eq!(
spec.constraints.allowed_domains,
Some(vec!["managed.example.com".to_string()])
);
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
assert!(spec.hard_deny_allowlist_misses);
}
#[test]
fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domains() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
let requirements = NetworkConstraints {
managed_allowed_domains_only: Some(true),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
)
.expect("managed-only mode should treat missing managed allowlist as empty");
assert!(spec.config.network.allowed_domains.is_empty());
assert_eq!(spec.constraints.allowed_domains, Some(Vec::new()));
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
assert!(spec.hard_deny_allowlist_misses);
}
#[test]
fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_managed_list() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
let requirements = NetworkConstraints {
managed_allowed_domains_only: Some(true),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::DangerFullAccess,
)
.expect("managed-only mode should treat missing managed allowlist as empty");
assert!(spec.config.network.allowed_domains.is_empty());
assert_eq!(spec.constraints.allowed_domains, Some(Vec::new()));
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
assert!(spec.hard_deny_allowlist_misses);
}
#[test]
fn requirements_denied_domains_are_a_baseline_for_default_mode() {
let mut config = NetworkProxyConfig::default();
config.network.denied_domains = vec!["blocked.example.com".to_string()];
let requirements = NetworkConstraints {
denied_domains: Some(vec!["managed-blocked.example.com".to_string()]),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
)
.expect("default mode should merge managed and user deny entries");
assert_eq!(
spec.config.network.denied_domains,
vec![
"managed-blocked.example.com".to_string(),
"blocked.example.com".to_string()
]
);
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true));
}
}
+214 -11
View File
@@ -1,17 +1,60 @@
use std::collections::BTreeMap;
use std::io;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PermissionsToml {
/// Network proxy settings from `[permissions.network]`.
/// User config can enable the proxy; managed requirements may still constrain values.
#[serde(flatten)]
pub entries: BTreeMap<String, PermissionProfileToml>,
}
impl PermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PermissionProfileToml {
pub filesystem: Option<FilesystemPermissionsToml>,
pub network: Option<NetworkToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
pub struct FilesystemPermissionsToml {
#[serde(flatten)]
pub entries: BTreeMap<String, FilesystemPermissionToml>,
}
impl FilesystemPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(untagged)]
pub enum FilesystemPermissionToml {
Access(FileSystemAccessMode),
Scoped(BTreeMap<String, FileSystemAccessMode>),
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkToml {
@@ -91,13 +134,173 @@ impl NetworkToml {
}
}
pub(crate) fn network_proxy_config_from_permissions(
permissions: Option<&PermissionsToml>,
pub(crate) fn network_proxy_config_from_profile_network(
network: Option<&NetworkToml>,
) -> NetworkProxyConfig {
permissions
.and_then(|permissions| permissions.network.as_ref())
.map_or_else(
NetworkProxyConfig::default,
NetworkToml::to_network_proxy_config,
)
network.map_or_else(
NetworkProxyConfig::default,
NetworkToml::to_network_proxy_config,
)
}
pub(crate) fn resolve_permission_profile<'a>(
permissions: &'a PermissionsToml,
profile_name: &str,
) -> io::Result<&'a PermissionProfileToml> {
permissions.entries.get(profile_name).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("default_permissions refers to undefined profile `{profile_name}`"),
)
})
}
pub(crate) fn compile_permission_profile(
permissions: &PermissionsToml,
profile_name: &str,
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
let profile = resolve_permission_profile(permissions, profile_name)?;
let filesystem = profile.filesystem.as_ref().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"permissions profile `{profile_name}` must define a `[permissions.{profile_name}.filesystem]` table"
),
)
})?;
if filesystem.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"permissions profile `{profile_name}` must define at least one filesystem entry"
),
));
}
let mut entries = Vec::new();
for (path, permission) in &filesystem.entries {
compile_filesystem_permission(path, permission, &mut entries)?;
}
let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref());
Ok((
FileSystemSandboxPolicy::restricted(entries),
network_sandbox_policy,
))
}
fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy {
let Some(network) = network else {
return NetworkSandboxPolicy::Restricted;
};
match network.enabled {
Some(true) => NetworkSandboxPolicy::Enabled,
_ => NetworkSandboxPolicy::Restricted,
}
}
fn compile_filesystem_permission(
path: &str,
permission: &FilesystemPermissionToml,
entries: &mut Vec<FileSystemSandboxEntry>,
) -> io::Result<()> {
match permission {
FilesystemPermissionToml::Access(access) => entries.push(FileSystemSandboxEntry {
path: compile_filesystem_path(path)?,
access: *access,
}),
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
entries.push(FileSystemSandboxEntry {
path: compile_scoped_filesystem_path(path, subpath)?,
access: *access,
});
}
}
}
Ok(())
}
fn compile_filesystem_path(path: &str) -> io::Result<FileSystemPath> {
if let Some(special) = parse_special_path(path)? {
return Ok(FileSystemPath::Special { value: special });
}
let path = parse_absolute_path(path)?;
Ok(FileSystemPath::Path { path })
}
fn compile_scoped_filesystem_path(path: &str, subpath: &str) -> io::Result<FileSystemPath> {
if subpath == "." {
return compile_filesystem_path(path);
}
if let Some(special) = parse_special_path(path)? {
if !matches!(special, FileSystemSpecialPath::ProjectRoots { .. }) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem path `{path}` does not support nested entries"),
));
}
return Ok(FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(parse_relative_subpath(subpath)?)),
});
}
let subpath = parse_relative_subpath(subpath)?;
let base = parse_absolute_path(path)?;
let path = AbsolutePathBuf::resolve_path_against_base(&subpath, base.as_path())?;
Ok(FileSystemPath::Path { path })
}
fn parse_special_path(path: &str) -> io::Result<Option<FileSystemSpecialPath>> {
let special = match path {
":root" => Some(FileSystemSpecialPath::Root),
":minimal" => Some(FileSystemSpecialPath::Minimal),
":project_roots" => Some(FileSystemSpecialPath::project_roots(None)),
":tmpdir" => Some(FileSystemSpecialPath::Tmpdir),
_ if path.starts_with(':') => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown filesystem special path `{path}`"),
));
}
_ => None,
};
Ok(special)
}
fn parse_absolute_path(path: &str) -> io::Result<AbsolutePathBuf> {
let path_ref = Path::new(path);
if !path_ref.is_absolute() && path != "~" && !path.starts_with("~/") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem path `{path}` must be absolute, use `~/...`, or start with `:`"),
));
}
AbsolutePathBuf::from_absolute_path(path_ref)
}
fn parse_relative_subpath(subpath: &str) -> io::Result<PathBuf> {
let path = Path::new(subpath);
if !subpath.is_empty()
&& path
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
return Ok(path.to_path_buf());
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"filesystem subpath `{}` must be a descendant path without `.` or `..` components",
path.display()
),
))
}
+2 -1
View File
@@ -3,6 +3,7 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use crate::config::ToolsToml;
use crate::config::types::Personality;
use crate::config::types::WindowsToml;
use crate::protocol::AskForApproval;
@@ -47,8 +48,8 @@ pub struct ConfigProfile {
pub include_apply_patch_tool: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
pub tools_web_search: Option<bool>,
pub tools_view_image: Option<bool>,
pub tools: Option<ToolsToml>,
pub web_search: Option<WebSearchMode>,
pub analytics: Option<crate::config::types::AnalyticsConfigToml>,
#[serde(default)]
+237 -39
View File
@@ -19,7 +19,6 @@ use tokio_util::sync::CancellationToken;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::SandboxErr;
use crate::get_platform_sandbox;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::ExecCommandOutputDeltaEvent;
@@ -33,7 +32,12 @@ use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use crate::text_encoding::bytes_to_string_smart;
use crate::tools::sandboxing::SandboxablePreference;
use codex_network_proxy::NetworkProxy;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use codex_utils_pty::process_group::kill_child_process_group;
pub const DEFAULT_EXEC_COMMAND_TIMEOUT_MS: u64 = 10_000;
@@ -53,12 +57,21 @@ const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB
///
/// This mirrors unified exec's output cap so a single runaway command cannot
/// OOM the process by dumping huge amounts of data to stdout/stderr.
const EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB
const EXEC_OUTPUT_MAX_BYTES: usize = DEFAULT_OUTPUT_BYTES_CAP;
/// Limit the number of ExecCommandOutputDelta events emitted per exec call.
/// Aggregation still collects full output; only the live event stream is capped.
pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000;
// Wait for the stdout/stderr collection tasks but guard against them
// hanging forever. In the normal case, both pipes are closed once the child
// terminates so the tasks exit quickly. However, if the child process
// spawned grandchildren that inherited its stdout/stderr file descriptors
// those pipes may stay open after we `kill` the direct child on timeout.
// That would cause the `read_capped` tasks to block on `read()`
// indefinitely, effectively hanging the whole agent.
pub const IO_DRAIN_TIMEOUT_MS: u64 = 2_000; // 2 s should be plenty for local pipes
#[derive(Debug)]
pub struct ExecParams {
pub command: Vec<String>,
@@ -72,6 +85,21 @@ pub struct ExecParams {
pub arg0: Option<String>,
}
fn select_process_exec_tool_sandbox_type(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
enforce_managed_network: bool,
) -> SandboxType {
SandboxManager::new().select_initial(
file_system_sandbox_policy,
network_sandbox_policy,
SandboxablePreference::Auto,
windows_sandbox_level,
enforce_managed_network,
)
}
/// Mechanism to terminate an exec invocation before it finishes naturally.
#[derive(Clone, Debug)]
pub enum ExecExpiration {
@@ -149,33 +177,50 @@ pub struct StdoutStream {
pub tx_event: Sender<Event>,
}
#[allow(clippy::too_many_arguments)]
pub async fn process_exec_tool_call(
params: ExecParams,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_cwd: &Path,
codex_linux_sandbox_exe: &Option<PathBuf>,
use_linux_sandbox_bwrap: bool,
stdout_stream: Option<StdoutStream>,
) -> Result<ExecToolCallOutput> {
let exec_req = build_exec_request(
params,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
sandbox_cwd,
codex_linux_sandbox_exe,
use_linux_sandbox_bwrap,
)?;
// Route through the sandboxing module for a single, unified execution path.
crate::sandboxing::execute_env(exec_req, stdout_stream).await
}
/// Transform a portable exec request into the concrete argv/env that should be
/// spawned under the requested sandbox policy.
pub fn build_exec_request(
params: ExecParams,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_cwd: &Path,
codex_linux_sandbox_exe: &Option<PathBuf>,
use_linux_sandbox_bwrap: bool,
) -> Result<ExecRequest> {
let windows_sandbox_level = params.windows_sandbox_level;
let enforce_managed_network = params.network.is_some();
let sandbox_type = match &sandbox_policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
if enforce_managed_network {
get_platform_sandbox(
windows_sandbox_level
!= codex_protocol::config_types::WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
}
_ => get_platform_sandbox(
windows_sandbox_level != codex_protocol::config_types::WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None),
};
let sandbox_type = select_process_exec_tool_sandbox_type(
file_system_sandbox_policy,
network_sandbox_policy,
windows_sandbox_level,
enforce_managed_network,
);
tracing::debug!("Sandbox type: {sandbox_type:?}");
let ExecParams {
@@ -215,6 +260,8 @@ pub async fn process_exec_tool_call(
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
policy: sandbox_policy,
file_system_policy: file_system_sandbox_policy,
network_policy: network_sandbox_policy,
sandbox: sandbox_type,
enforce_managed_network,
network: network.as_ref(),
@@ -226,9 +273,7 @@ pub async fn process_exec_tool_call(
windows_sandbox_level,
})
.map_err(CodexErr::from)?;
// Route through the sandboxing module for a single, unified execution path.
crate::sandboxing::execute_env(exec_req, stdout_stream).await
Ok(exec_req)
}
pub(crate) async fn execute_exec_request(
@@ -247,9 +292,12 @@ pub(crate) async fn execute_exec_request(
windows_sandbox_level,
sandbox_permissions,
sandbox_policy: _sandbox_policy_from_env,
file_system_sandbox_policy,
network_sandbox_policy,
justification,
arg0,
} = exec_request;
let _ = _sandbox_policy_from_env;
let params = ExecParams {
command,
@@ -264,7 +312,16 @@ pub(crate) async fn execute_exec_request(
};
let start = Instant::now();
let raw_output_result = exec(params, sandbox, sandbox_policy, stdout_stream, after_spawn).await;
let raw_output_result = exec(
params,
sandbox,
sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
stdout_stream,
after_spawn,
)
.await;
let duration = start.elapsed();
finalize_exec_result(raw_output_result, sandbox, duration)
}
@@ -693,16 +750,21 @@ async fn exec(
params: ExecParams,
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> Result<RawExecToolCallOutput> {
#[cfg(target_os = "windows")]
if sandbox == SandboxType::WindowsRestrictedToken
&& !matches!(
if sandbox == SandboxType::WindowsRestrictedToken {
if let Some(reason) = unsupported_windows_restricted_token_sandbox_reason(
sandbox,
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
{
file_system_sandbox_policy,
network_sandbox_policy,
) {
return Err(CodexErr::Io(io::Error::other(reason)));
}
return exec_windows_sandbox(params, sandbox_policy).await;
}
let ExecParams {
@@ -731,7 +793,7 @@ async fn exec(
args: args.into(),
arg0: arg0_ref,
cwd,
sandbox_policy,
network_sandbox_policy,
// The environment already has attempt-scoped proxy settings from
// apply_to_env_for_attempt above. Passing network here would reapply
// non-attempt proxy vars and drop attempt correlation metadata.
@@ -746,6 +808,43 @@ async fn exec(
consume_truncated_output(child, expiration, stdout_stream).await
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn should_use_windows_restricted_token_sandbox(
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
) -> bool {
sandbox == SandboxType::WindowsRestrictedToken
&& file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted
&& !matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
}
#[cfg(any(target_os = "windows", test))]
fn unsupported_windows_restricted_token_sandbox_reason(
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
) -> Option<String> {
if should_use_windows_restricted_token_sandbox(
sandbox,
sandbox_policy,
file_system_sandbox_policy,
) {
return None;
}
(sandbox == SandboxType::WindowsRestrictedToken).then(|| {
format!(
"windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed",
file_system_sandbox_policy.kind,
)
})
}
/// Consumes the output of a child process, truncating it so it is suitable for
/// use as the output of a `shell` tool call. Also enforces specified timeout.
async fn consume_truncated_output(
@@ -796,16 +895,6 @@ async fn consume_truncated_output(
}
};
// Wait for the stdout/stderr collection tasks but guard against them
// hanging forever. In the normal case, both pipes are closed once the child
// terminates so the tasks exit quickly. However, if the child process
// spawned grandchildren that inherited its stdout/stderr file descriptors
// those pipes may stay open after we `kill` the direct child on timeout.
// That would cause the `read_capped` tasks to block on `read()`
// indefinitely, effectively hanging the whole agent.
const IO_DRAIN_TIMEOUT_MS: u64 = 2_000; // 2 s should be plenty for local pipes
// We need mutable bindings so we can `abort()` them on timeout.
use tokio::task::JoinHandle;
@@ -1098,6 +1187,111 @@ mod tests {
assert_eq!(aggregated.truncated_after_lines, None);
}
#[test]
fn windows_restricted_token_skips_external_sandbox_policies() {
let policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
should_use_windows_restricted_token_sandbox(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
),
false
);
}
#[test]
fn windows_restricted_token_runs_for_legacy_restricted_policies() {
let policy = SandboxPolicy::new_read_only_policy();
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
should_use_windows_restricted_token_sandbox(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
),
true
);
}
#[test]
fn windows_restricted_token_rejects_network_only_restrictions() {
let policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
let file_system_policy = FileSystemSandboxPolicy::unrestricted();
assert_eq!(
unsupported_windows_restricted_token_sandbox_reason(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
Some(
"windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string()
)
);
}
#[test]
fn windows_restricted_token_allows_legacy_restricted_policies() {
let policy = SandboxPolicy::new_read_only_policy();
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
unsupported_windows_restricted_token_sandbox_reason(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
None
);
}
#[test]
fn windows_restricted_token_allows_legacy_workspace_write_policies() {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
assert_eq!(
unsupported_windows_restricted_token_sandbox_reason(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
None
);
}
#[test]
fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() {
let expected = crate::get_platform_sandbox(false).unwrap_or(SandboxType::None);
assert_eq!(
select_process_exec_tool_sandbox_type(
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
codex_protocol::config_types::WindowsSandboxLevel::Disabled,
false,
),
expected
);
}
#[cfg(unix)]
#[test]
fn sandbox_detection_flags_sigsys_exit_code() {
@@ -1140,6 +1334,8 @@ mod tests {
params,
SandboxType::None,
&SandboxPolicy::new_read_only_policy(),
&FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()),
NetworkSandboxPolicy::Restricted,
None,
None,
)
@@ -1196,6 +1392,8 @@ mod tests {
let result = process_exec_tool_call(
params,
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess),
NetworkSandboxPolicy::Enabled,
cwd.as_path(),
&None,
false,
+10 -6
View File
@@ -12,7 +12,7 @@ use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::WarningEvent;
use codex_config::CONFIG_TOML_FILE;
use codex_otel::OtelManager;
use codex_otel::SessionTelemetry;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -213,10 +213,17 @@ impl FeatureOverrides {
fn apply(self, features: &mut Features) {
LegacyFeatureToggles {
include_apply_patch_tool: self.include_apply_patch_tool,
tools_web_search: self.web_search_request,
..Default::default()
}
.apply(features);
if let Some(enabled) = self.web_search_request {
if enabled {
features.enable(Feature::WebSearchRequest);
} else {
features.disable(Feature::WebSearchRequest);
}
features.record_legacy_usage("web_search_request", Feature::WebSearchRequest);
}
}
}
@@ -278,7 +285,7 @@ impl Features {
self.legacy_usages.iter()
}
pub fn emit_metrics(&self, otel: &OtelManager) {
pub fn emit_metrics(&self, otel: &SessionTelemetry) {
for feature in FEATURES {
if matches!(feature.stage, Stage::Removed) {
continue;
@@ -342,7 +349,6 @@ impl Features {
let base_legacy = LegacyFeatureToggles {
experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch,
experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool,
tools_web_search: cfg.tools.as_ref().and_then(|t| t.web_search),
..Default::default()
};
base_legacy.apply(&mut features);
@@ -357,7 +363,6 @@ impl Features {
.experimental_use_freeform_apply_patch,
experimental_use_unified_exec_tool: config_profile.experimental_use_unified_exec_tool,
tools_web_search: config_profile.tools_web_search,
};
profile_legacy.apply(&mut features);
if let Some(profile_features) = config_profile.features.as_ref() {
@@ -388,7 +393,6 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option<String>
Feature::WebSearchRequest | Feature::WebSearchCached => {
let label = match alias {
"web_search" => "[features].web_search",
"tools.web_search" => "[tools].web_search",
"features.web_search_request" | "web_search_request" => {
"[features].web_search_request"
}
-7
View File
@@ -62,7 +62,6 @@ pub struct LegacyFeatureToggles {
pub include_apply_patch_tool: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub tools_web_search: Option<bool>,
}
impl LegacyFeatureToggles {
@@ -85,12 +84,6 @@ impl LegacyFeatureToggles {
self.experimental_use_unified_exec_tool,
"experimental_use_unified_exec_tool",
);
set_if_some(
features,
Feature::WebSearchRequest,
self.tools_web_search,
"tools.web_search",
);
}
}
+74 -32
View File
@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
@@ -26,21 +27,12 @@ use tokio::time::timeout;
/// directory. If you need Codex to work from such a checkout simply pass the
/// `--allow-no-git-exec` CLI flag that disables the repo requirement.
pub fn get_git_repo_root(base_dir: &Path) -> Option<PathBuf> {
let mut dir = base_dir.to_path_buf();
loop {
if dir.join(".git").exists() {
return Some(dir);
}
// Pop one component (go up one directory). `pop` returns false when
// we have reached the filesystem root.
if !dir.pop() {
break;
}
}
None
let base = if base_dir.is_dir() {
base_dir
} else {
base_dir.parent()?
};
find_ancestor_git_entry(base).map(|(repo_root, _)| repo_root)
}
/// Timeout for git commands to prevent freezing on large repositories
@@ -609,30 +601,53 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
/// Resolve the path that should be used for trust checks. Similar to
/// `[get_git_repo_root]`, but resolves to the root of the main
/// repository. Handles worktrees.
/// repository. Handles worktrees via filesystem inspection without invoking
/// the `git` executable.
pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
let (repo_root, dot_git) = find_ancestor_git_entry(base)?;
if dot_git.is_dir() {
return Some(canonicalize_or_raw(repo_root));
}
// TODO: we should make this async, but it's primarily used deep in
// callstacks of sync code, and should almost always be fast
let git_dir_out = std::process::Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.current_dir(base)
.output()
.ok()?;
if !git_dir_out.status.success() {
let git_dir_s = std::fs::read_to_string(&dot_git).ok()?;
let git_dir_rel = git_dir_s.trim().strip_prefix("gitdir:")?.trim();
if git_dir_rel.is_empty() {
return None;
}
let git_dir_s = String::from_utf8(git_dir_out.stdout)
.ok()?
.trim()
.to_string();
let git_dir_path_raw = resolve_path(base, &PathBuf::from(&git_dir_s));
let git_dir_path = canonicalize_or_raw(resolve_path(&repo_root, &PathBuf::from(git_dir_rel)));
let worktrees_dir = git_dir_path.parent()?;
if worktrees_dir.file_name() != Some(OsStr::new("worktrees")) {
return None;
}
// Normalize to handle macOS /var vs /private/var and resolve ".." segments.
let git_dir_path = std::fs::canonicalize(&git_dir_path_raw).unwrap_or(git_dir_path_raw);
git_dir_path.parent().map(Path::to_path_buf)
let common_dir = worktrees_dir.parent()?;
let main_repo_root = common_dir.parent()?;
Some(canonicalize_or_raw(main_repo_root.to_path_buf()))
}
fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> {
let mut dir = base_dir.to_path_buf();
loop {
let dot_git = dir.join(".git");
if dot_git.exists() {
return Some((dir, dot_git));
}
// Pop one component (go up one directory). `pop` returns false when
// we have reached the filesystem root.
if !dir.pop() {
break;
}
}
None
}
fn canonicalize_or_raw(path: PathBuf) -> PathBuf {
std::fs::canonicalize(&path).unwrap_or(path)
}
/// Returns a list of local git branches.
@@ -1150,6 +1165,33 @@ mod tests {
assert_eq!(got_nested, expected);
}
#[test]
fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() {
let tmp = TempDir::new().expect("tempdir");
let repo_root = tmp.path().join("repo");
let common_dir = repo_root.join(".git");
let worktree_git_dir = common_dir.join("worktrees").join("feature-x");
let worktree_root = tmp.path().join("wt");
std::fs::create_dir_all(&worktree_git_dir).unwrap();
std::fs::create_dir_all(&worktree_root).unwrap();
std::fs::create_dir_all(worktree_root.join("nested")).unwrap();
std::fs::write(
worktree_root.join(".git"),
format!("gitdir: {}\n", worktree_git_dir.display()),
)
.unwrap();
let expected = std::fs::canonicalize(&repo_root).unwrap();
assert_eq!(
resolve_root_git_project_for_trust(&worktree_root),
Some(expected.clone())
);
assert_eq!(
resolve_root_git_project_for_trust(&worktree_root.join("nested")),
Some(expected)
);
}
#[test]
fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {
let tmp = TempDir::new().expect("tempdir");
+2 -1
View File
@@ -3,6 +3,7 @@ use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use codex_network_proxy::NetworkProxy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
@@ -44,7 +45,7 @@ where
args,
arg0,
cwd: command_cwd,
sandbox_policy,
network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy),
network,
stdio_policy,
env,
+1
View File
@@ -51,6 +51,7 @@ pub mod network_proxy_loader;
pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD;
pub use mcp_connection_manager::SandboxState;
pub use text_encoding::bytes_to_string_smart;
mod mcp_tool_call;
mod memories;
mod mentions;
+3 -3
View File
@@ -107,7 +107,7 @@ pub(crate) async fn handle_mcp_tool_call(
.await;
let status = if result.is_ok() { "ok" } else { "error" };
turn_context
.otel_manager
.session_telemetry
.counter("codex.mcp.call", 1, &[("status", status)]);
return ResponseInputItem::McpToolCallOutput { call_id, result };
}
@@ -188,7 +188,7 @@ pub(crate) async fn handle_mcp_tool_call(
let status = if result.is_ok() { "ok" } else { "error" };
turn_context
.otel_manager
.session_telemetry
.counter("codex.mcp.call", 1, &[("status", status)]);
return ResponseInputItem::McpToolCallOutput { call_id, result };
@@ -229,7 +229,7 @@ pub(crate) async fn handle_mcp_tool_call(
let status = if result.is_ok() { "ok" } else { "error" };
turn_context
.otel_manager
.session_telemetry
.counter("codex.mcp.call", 1, &[("status", status)]);
ResponseInputItem::McpToolCallOutput { call_id, result }
+17 -17
View File
@@ -12,7 +12,7 @@ use crate::memories::prompts::build_stage_one_input_message;
use crate::rollout::INTERACTIVE_SESSION_SOURCES;
use crate::rollout::policy::should_persist_response_item_for_memories;
use codex_api::ResponseEvent;
use codex_otel::OtelManager;
use codex_otel::SessionTelemetry;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::BaseInstructions;
@@ -35,7 +35,7 @@ use tracing::warn;
#[derive(Clone, Debug)]
pub(in crate::memories) struct RequestContext {
pub(in crate::memories) model_info: ModelInfo,
pub(in crate::memories) otel_manager: OtelManager,
pub(in crate::memories) session_telemetry: SessionTelemetry,
pub(in crate::memories) reasoning_effort: Option<ReasoningEffortConfig>,
pub(in crate::memories) reasoning_summary: ReasoningSummaryConfig,
pub(in crate::memories) service_tier: Option<ServiceTier>,
@@ -85,7 +85,7 @@ struct StageOneOutput {
pub(in crate::memories) async fn run(session: &Arc<Session>, config: &Config) {
let _phase_one_e2e_timer = session
.services
.otel_manager
.session_telemetry
.start_timer(metrics::MEMORY_PHASE_ONE_E2E_MS, &[])
.ok();
@@ -94,7 +94,7 @@ pub(in crate::memories) async fn run(session: &Arc<Session>, config: &Config) {
return;
};
if claimed_candidates.is_empty() {
session.services.otel_manager.counter(
session.services.session_telemetry.counter(
metrics::MEMORY_PHASE_ONE_JOBS,
1,
&[("status", "skipped_no_candidates")],
@@ -168,7 +168,7 @@ impl RequestContext {
Self {
model_info,
turn_metadata_header,
otel_manager: turn_context.otel_manager.clone(),
session_telemetry: turn_context.session_telemetry.clone(),
reasoning_effort: Some(phase_one::REASONING_EFFORT),
reasoning_summary: turn_context.reasoning_summary,
service_tier: turn_context.config.service_tier,
@@ -208,7 +208,7 @@ async fn claim_startup_jobs(
Ok(claims) => Some(claims),
Err(err) => {
warn!("state db claim_stage1_jobs_for_startup failed during memories startup: {err}");
session.services.otel_manager.counter(
session.services.session_telemetry.counter(
metrics::MEMORY_PHASE_ONE_JOBS,
1,
&[("status", "failed_claim")],
@@ -347,7 +347,7 @@ mod job {
.stream(
&prompt,
&stage_one_context.model_info,
&stage_one_context.otel_manager,
&stage_one_context.session_telemetry,
stage_one_context.reasoning_effort,
stage_one_context.reasoning_summary,
stage_one_context.service_tier,
@@ -516,60 +516,60 @@ fn aggregate_stats(outcomes: Vec<JobResult>) -> Stats {
fn emit_metrics(session: &Session, counts: &Stats) {
if counts.claimed > 0 {
session.services.otel_manager.counter(
session.services.session_telemetry.counter(
metrics::MEMORY_PHASE_ONE_JOBS,
counts.claimed as i64,
&[("status", "claimed")],
);
}
if counts.succeeded_with_output > 0 {
session.services.otel_manager.counter(
session.services.session_telemetry.counter(
metrics::MEMORY_PHASE_ONE_JOBS,
counts.succeeded_with_output as i64,
&[("status", "succeeded")],
);
session.services.otel_manager.counter(
session.services.session_telemetry.counter(
metrics::MEMORY_PHASE_ONE_OUTPUT,
counts.succeeded_with_output as i64,
&[],
);
}
if counts.succeeded_no_output > 0 {
session.services.otel_manager.counter(
session.services.session_telemetry.counter(
metrics::MEMORY_PHASE_ONE_JOBS,
counts.succeeded_no_output as i64,
&[("status", "succeeded_no_output")],
);
}
if counts.failed > 0 {
session.services.otel_manager.counter(
session.services.session_telemetry.counter(
metrics::MEMORY_PHASE_ONE_JOBS,
counts.failed as i64,
&[("status", "failed")],
);
}
if let Some(token_usage) = counts.total_token_usage.as_ref() {
session.services.otel_manager.histogram(
session.services.session_telemetry.histogram(
metrics::MEMORY_PHASE_ONE_TOKEN_USAGE,
token_usage.total_tokens.max(0),
&[("token_type", "total")],
);
session.services.otel_manager.histogram(
session.services.session_telemetry.histogram(
metrics::MEMORY_PHASE_ONE_TOKEN_USAGE,
token_usage.input_tokens.max(0),
&[("token_type", "input")],
);
session.services.otel_manager.histogram(
session.services.session_telemetry.histogram(
metrics::MEMORY_PHASE_ONE_TOKEN_USAGE,
token_usage.cached_input(),
&[("token_type", "cached_input")],
);
session.services.otel_manager.histogram(
session.services.session_telemetry.histogram(
metrics::MEMORY_PHASE_ONE_TOKEN_USAGE,
token_usage.output_tokens.max(0),
&[("token_type", "output")],
);
session.services.otel_manager.histogram(
session.services.session_telemetry.histogram(
metrics::MEMORY_PHASE_ONE_TOKEN_USAGE,
token_usage.reasoning_output_tokens.max(0),
&[("token_type", "reasoning_output")],
+12 -8
View File
@@ -43,7 +43,7 @@ struct Counters {
pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
let phase_two_e2e_timer = session
.services
.otel_manager
.session_telemetry
.start_timer(metrics::MEMORY_PHASE_TWO_E2E_MS, &[])
.ok();
@@ -59,7 +59,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
let claim = match job::claim(session, db).await {
Ok(claim) => claim,
Err(e) => {
session.services.otel_manager.counter(
session.services.session_telemetry.counter(
metrics::MEMORY_PHASE_TWO_JOBS,
1,
&[("status", e)],
@@ -183,7 +183,7 @@ mod job {
session: &Arc<Session>,
db: &StateRuntime,
) -> Result<Claim, &'static str> {
let otel_manager = &session.services.otel_manager;
let session_telemetry = &session.services.session_telemetry;
let claim = db
.try_claim_global_phase2_job(session.conversation_id, phase_two::JOB_LEASE_SECONDS)
.await
@@ -196,7 +196,11 @@ mod job {
ownership_token,
input_watermark,
} => {
otel_manager.counter(metrics::MEMORY_PHASE_TWO_JOBS, 1, &[("status", "claimed")]);
session_telemetry.counter(
metrics::MEMORY_PHASE_TWO_JOBS,
1,
&[("status", "claimed")],
);
(ownership_token, input_watermark)
}
codex_state::Phase2JobClaimOutcome::SkippedNotDirty => return Err("skipped_not_dirty"),
@@ -212,7 +216,7 @@ mod job {
claim: &Claim,
reason: &'static str,
) {
session.services.otel_manager.counter(
session.services.session_telemetry.counter(
metrics::MEMORY_PHASE_TWO_JOBS,
1,
&[("status", reason)],
@@ -244,7 +248,7 @@ mod job {
selected_outputs: &[codex_state::Stage1Output],
reason: &'static str,
) {
session.services.otel_manager.counter(
session.services.session_telemetry.counter(
metrics::MEMORY_PHASE_TWO_JOBS,
1,
&[("status", reason)],
@@ -450,7 +454,7 @@ pub(super) fn get_watermark(
}
fn emit_metrics(session: &Arc<Session>, counters: Counters) {
let otel = session.services.otel_manager.clone();
let otel = session.services.session_telemetry.clone();
if counters.input > 0 {
otel.counter(metrics::MEMORY_PHASE_TWO_INPUT, counters.input, &[]);
}
@@ -463,7 +467,7 @@ fn emit_metrics(session: &Arc<Session>, counters: Counters) {
}
fn emit_token_usage_metrics(session: &Arc<Session>, token_usage: &TokenUsage) {
let otel = session.services.otel_manager.clone();
let otel = session.services.session_telemetry.clone();
otel.histogram(
metrics::MEMORY_PHASE_TWO_TOKEN_USAGE,
token_usage.total_tokens.max(0),
+1 -1
View File
@@ -39,7 +39,7 @@ pub(crate) async fn emit_metric_for_tool_read(invocation: &ToolInvocation, succe
let success = if success { "true" } else { "false" };
for kind in kinds {
invocation.turn.otel_manager.counter(
invocation.turn.session_telemetry.counter(
MEMORIES_USAGE_METRIC,
1,
&[
+3 -3
View File
@@ -6,7 +6,7 @@ use crate::error::CodexErr;
use crate::error::Result;
use codex_api::RawMemory as ApiRawMemory;
use codex_api::RawMemoryMetadata as ApiRawMemoryMetadata;
use codex_otel::OtelManager;
use codex_otel::SessionTelemetry;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use serde_json::Map;
@@ -38,7 +38,7 @@ pub async fn build_memories_from_trace_files(
trace_paths: &[PathBuf],
model_info: &ModelInfo,
effort: Option<ReasoningEffortConfig>,
otel_manager: &OtelManager,
session_telemetry: &SessionTelemetry,
) -> Result<Vec<BuiltMemory>> {
if trace_paths.is_empty() {
return Ok(Vec::new());
@@ -51,7 +51,7 @@ pub async fn build_memories_from_trace_files(
let raw_memories = prepared.iter().map(|trace| trace.payload.clone()).collect();
let output = client
.summarize_memories(raw_memories, model_info, effort, otel_manager)
.summarize_memories(raw_memories, model_info, effort, session_telemetry)
.await?;
if output.len() != prepared.len() {
return Err(CodexErr::InvalidRequest(format!(
+41 -29
View File
@@ -1,6 +1,7 @@
use crate::config::NetworkToml;
use crate::config::PermissionsToml;
use crate::config::find_codex_home;
use crate::config::resolve_permission_profile;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
@@ -118,13 +119,7 @@ fn network_constraints_from_trusted_layers(
}
let parsed = network_tables_from_toml(&layer.config)?;
if let Some(network) = parsed.network {
apply_network_constraints(network, &mut constraints);
}
if let Some(network) = parsed
.permissions
.and_then(|permissions| permissions.network)
{
if let Some(network) = selected_network_from_tables(parsed)? {
apply_network_constraints(network, &mut constraints);
}
}
@@ -165,7 +160,7 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx
#[derive(Debug, Clone, Default, Deserialize)]
struct NetworkTablesToml {
network: Option<NetworkToml>,
default_permissions: Option<String>,
permissions: Option<PermissionsToml>,
}
@@ -176,16 +171,24 @@ fn network_tables_from_toml(value: &toml::Value) -> Result<NetworkTablesToml> {
.context("failed to deserialize network tables from config")
}
fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) {
if let Some(network) = parsed.network {
network.apply_to_network_proxy_config(config);
}
if let Some(network) = parsed
fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<NetworkToml>> {
let Some(default_permissions) = parsed.default_permissions else {
return Ok(None);
};
let permissions = parsed
.permissions
.and_then(|permissions| permissions.network)
{
.context("default_permissions requires a `[permissions]` table for network settings")?;
let profile = resolve_permission_profile(&permissions, &default_permissions)
.map_err(anyhow::Error::from)?;
Ok(profile.network.clone())
}
fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) -> Result<()> {
if let Some(network) = selected_network_from_tables(parsed)? {
network.apply_to_network_proxy_config(config);
}
Ok(())
}
fn config_from_layers(
@@ -195,7 +198,7 @@ fn config_from_layers(
let mut config = NetworkProxyConfig::default();
for layer in layers.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) {
let parsed = network_tables_from_toml(&layer.config)?;
apply_network_tables(&mut config, parsed);
apply_network_tables(&mut config, parsed)?;
}
apply_exec_policy_network_rules(&mut config, exec_policy);
Ok(config)
@@ -310,17 +313,21 @@ mod tests {
use pretty_assertions::assert_eq;
#[test]
fn higher_precedence_network_table_beats_lower_permissions_network_table() {
let lower_permissions: toml::Value = toml::from_str(
fn higher_precedence_profile_network_beats_lower_profile_network() {
let lower_network: toml::Value = toml::from_str(
r#"
[permissions.network]
default_permissions = "workspace"
[permissions.workspace.network]
allowed_domains = ["lower.example.com"]
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
[network]
default_permissions = "workspace"
[permissions.workspace.network]
allowed_domains = ["higher.example.com"]
"#,
)
@@ -329,12 +336,14 @@ allowed_domains = ["higher.example.com"]
let mut config = NetworkProxyConfig::default();
apply_network_tables(
&mut config,
network_tables_from_toml(&lower_permissions).expect("lower layer should deserialize"),
);
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
)
.expect("lower layer should apply");
apply_network_tables(
&mut config,
network_tables_from_toml(&higher_network).expect("higher layer should deserialize"),
);
)
.expect("higher layer should apply");
assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]);
}
@@ -382,15 +391,18 @@ allowed_domains = ["higher.example.com"]
fn apply_network_constraints_includes_allow_all_unix_sockets_flag() {
let config: toml::Value = toml::from_str(
r#"
[network]
default_permissions = "workspace"
[permissions.workspace.network]
dangerously_allow_all_unix_sockets = true
"#,
)
.expect("network table should parse");
let network = network_tables_from_toml(&config)
.expect("network table should deserialize")
.network
.expect("network table should be present");
.expect("permissions profile should parse");
let network = selected_network_from_tables(
network_tables_from_toml(&config).expect("permissions profile should deserialize"),
)
.expect("permissions profile should select a network table")
.expect("network table should be present");
let mut constraints = NetworkProxyConstraints::default();
apply_network_constraints(network, &mut constraints);
+353
View File
@@ -0,0 +1,353 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Output;
use std::process::Stdio;
use std::thread;
use std::time::Duration;
use std::time::Instant;
const OPENAI_PLUGINS_REPO_URL: &str = "https://github.com/openai/plugins.git";
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
}
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<(), String> {
let repo_path = curated_plugins_repo_path(codex_home);
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
let remote_sha = git_ls_remote_head_sha()?;
let local_sha = read_local_sha(&repo_path, &sha_path);
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() {
return Ok(());
}
let Some(parent) = repo_path.parent() else {
return Err(format!(
"failed to determine curated plugins parent directory for {}",
repo_path.display()
));
};
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins parent directory {}: {err}",
parent.display()
)
})?;
let clone_dir = tempfile::Builder::new()
.prefix("plugins-clone-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create temporary curated plugins directory in {}: {err}",
parent.display()
)
})?;
let cloned_repo_path = clone_dir.path().join("repo");
let clone_output = run_git_command_with_timeout(
Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("clone")
.arg("--depth")
.arg("1")
.arg(OPENAI_PLUGINS_REPO_URL)
.arg(&cloned_repo_path),
"git clone curated plugins repo",
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&clone_output, "git clone curated plugins repo")?;
let cloned_sha = git_head_sha(&cloned_repo_path)?;
if cloned_sha != remote_sha {
return Err(format!(
"curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}"
));
}
if repo_path.exists() {
let backup_dir = tempfile::Builder::new()
.prefix("plugins-backup-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create curated plugins backup directory in {}: {err}",
parent.display()
)
})?;
let backup_repo_path = backup_dir.path().join("repo");
fs::rename(&repo_path, &backup_repo_path).map_err(|err| {
format!(
"failed to move previous curated plugins repo out of the way at {}: {err}",
repo_path.display()
)
})?;
if let Err(err) = fs::rename(&cloned_repo_path, &repo_path) {
let rollback_result = fs::rename(&backup_repo_path, &repo_path);
return match rollback_result {
Ok(()) => Err(format!(
"failed to activate new curated plugins repo at {}: {err}",
repo_path.display()
)),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join("repo");
Err(format!(
"failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}",
repo_path.display(),
backup_path.display()
))
}
};
}
} else {
fs::rename(&cloned_repo_path, &repo_path).map_err(|err| {
format!(
"failed to activate curated plugins repo at {}: {err}",
repo_path.display()
)
})?;
}
if let Some(parent) = sha_path.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins sha directory {}: {err}",
parent.display()
)
})?;
}
fs::write(&sha_path, format!("{cloned_sha}\n")).map_err(|err| {
format!(
"failed to write curated plugins sha file {}: {err}",
sha_path.display()
)
})?;
Ok(())
}
fn read_local_sha(repo_path: &Path, sha_path: &Path) -> Option<String> {
if repo_path.join(".git").is_dir()
&& let Ok(sha) = git_head_sha(repo_path)
{
return Some(sha);
}
fs::read_to_string(sha_path)
.ok()
.map(|sha| sha.trim().to_string())
.filter(|sha| !sha.is_empty())
}
fn git_ls_remote_head_sha() -> Result<String, String> {
let output = run_git_command_with_timeout(
Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("ls-remote")
.arg(OPENAI_PLUGINS_REPO_URL)
.arg("HEAD"),
"git ls-remote curated plugins repo",
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&output, "git ls-remote curated plugins repo")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(first_line) = stdout.lines().next() else {
return Err("git ls-remote returned empty output for curated plugins repo".to_string());
};
let Some((sha, _)) = first_line.split_once('\t') else {
return Err(format!(
"unexpected git ls-remote output for curated plugins repo: {first_line}"
));
};
if sha.is_empty() {
return Err("git ls-remote returned empty sha for curated plugins repo".to_string());
}
Ok(sha.to_string())
}
fn git_head_sha(repo_path: &Path) -> Result<String, String> {
let output = Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("-C")
.arg(repo_path)
.arg("rev-parse")
.arg("HEAD")
.output()
.map_err(|err| {
format!(
"failed to run git rev-parse HEAD in {}: {err}",
repo_path.display()
)
})?;
ensure_git_success(&output, "git rev-parse HEAD")?;
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
if sha.is_empty() {
return Err(format!(
"git rev-parse HEAD returned empty output in {}",
repo_path.display()
));
}
Ok(sha)
}
fn run_git_command_with_timeout(
command: &mut Command,
context: &str,
timeout: Duration,
) -> Result<Output, String> {
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| format!("failed to run {context}: {err}"))?;
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context}: {err}"));
}
Ok(None) => {}
Err(err) => return Err(format!("failed to poll {context}: {err}")),
}
if start.elapsed() >= timeout {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context}: {err}"));
}
Ok(None) => {}
Err(err) => return Err(format!("failed to poll {context}: {err}")),
}
let _ = child.kill();
let output = child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return if stderr.is_empty() {
Err(format!("{context} timed out after {}s", timeout.as_secs()))
} else {
Err(format!(
"{context} timed out after {}s: {stderr}",
timeout.as_secs()
))
};
}
thread::sleep(Duration::from_millis(100));
}
}
fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> {
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
Err(format!("{context} failed with status {}", output.status))
} else {
Err(format!(
"{context} failed with status {}: {stderr}",
output.status
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
let tmp = tempdir().expect("tempdir");
assert_eq!(
curated_plugins_repo_path(tmp.path()),
tmp.path().join(".tmp/plugins")
);
}
#[test]
fn read_local_sha_prefers_repo_head_when_available() {
let tmp = tempdir().expect("tempdir");
let repo_path = tmp.path().join("repo");
let sha_path = tmp.path().join("plugins.sha");
fs::create_dir_all(&repo_path).expect("create repo dir");
fs::write(&sha_path, "abc123\n").expect("write sha");
let init_output = Command::new("git")
.arg("init")
.arg(&repo_path)
.output()
.expect("git init should run");
ensure_git_success(&init_output, "git init").expect("git init should succeed");
let config_name_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("config")
.arg("user.name")
.arg("Codex")
.output()
.expect("git config user.name should run");
ensure_git_success(&config_name_output, "git config user.name")
.expect("git config user.name should succeed");
let config_email_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("config")
.arg("user.email")
.arg("codex@example.com")
.output()
.expect("git config user.email should run");
ensure_git_success(&config_email_output, "git config user.email")
.expect("git config user.email should succeed");
fs::write(repo_path.join("README.md"), "demo\n").expect("write file");
let add_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("add")
.arg(".")
.output()
.expect("git add should run");
ensure_git_success(&add_output, "git add").expect("git add should succeed");
let commit_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("commit")
.arg("-m")
.arg("init")
.output()
.expect("git commit should run");
ensure_git_success(&commit_output, "git commit").expect("git commit should succeed");
let sha = read_local_sha(&repo_path, &sha_path);
assert_eq!(sha, Some(git_head_sha(&repo_path).expect("repo head sha")));
}
#[test]
fn read_local_sha_falls_back_to_sha_file() {
let tmp = tempdir().expect("tempdir");
let repo_path = tmp.path().join("repo");
let sha_path = tmp.path().join("plugins.sha");
fs::write(&sha_path, "abc123\n").expect("write sha");
let sha = read_local_sha(&repo_path, &sha_path);
assert_eq!(sha.as_deref(), Some("abc123"));
}
}
File diff suppressed because it is too large Load Diff
+196
View File
@@ -1,13 +1,89 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use std::fs;
use std::path::Component;
use std::path::Path;
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PluginManifest {
#[serde(default)]
pub(crate) name: String,
#[serde(default)]
pub(crate) description: Option<String>,
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
// resolving them under the plugin root.
#[serde(default)]
skills: Option<String>,
#[serde(default)]
mcp_servers: Option<String>,
#[serde(default)]
apps: Option<String>,
#[serde(default)]
interface: Option<PluginManifestInterface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifestPaths {
pub skills: Option<AbsolutePathBuf>,
pub mcp_servers: Option<AbsolutePathBuf>,
pub apps: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifestInterfaceSummary {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub long_description: Option<String>,
pub developer_name: Option<String>,
pub category: Option<String>,
pub capabilities: Vec<String>,
pub website_url: Option<String>,
pub privacy_policy_url: Option<String>,
pub terms_of_service_url: Option<String>,
pub default_prompt: Option<String>,
pub brand_color: Option<String>,
pub composer_icon: Option<AbsolutePathBuf>,
pub logo: Option<AbsolutePathBuf>,
pub screenshots: Vec<AbsolutePathBuf>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginManifestInterface {
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
short_description: Option<String>,
#[serde(default)]
long_description: Option<String>,
#[serde(default)]
developer_name: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default)]
#[serde(alias = "websiteURL")]
website_url: Option<String>,
#[serde(default)]
#[serde(alias = "privacyPolicyURL")]
privacy_policy_url: Option<String>,
#[serde(default)]
#[serde(alias = "termsOfServiceURL")]
terms_of_service_url: Option<String>,
#[serde(default)]
default_prompt: Option<String>,
#[serde(default)]
brand_color: Option<String>,
#[serde(default)]
composer_icon: Option<String>,
#[serde(default)]
logo: Option<String>,
#[serde(default)]
screenshots: Vec<String>,
}
pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
@@ -36,3 +112,123 @@ pub(crate) fn plugin_manifest_name(manifest: &PluginManifest, plugin_root: &Path
.unwrap_or(&manifest.name)
.to_string()
}
pub(crate) fn plugin_manifest_interface(
manifest: &PluginManifest,
plugin_root: &Path,
) -> Option<PluginManifestInterfaceSummary> {
let interface = manifest.interface.as_ref()?;
let interface = PluginManifestInterfaceSummary {
display_name: interface.display_name.clone(),
short_description: interface.short_description.clone(),
long_description: interface.long_description.clone(),
developer_name: interface.developer_name.clone(),
category: interface.category.clone(),
capabilities: interface.capabilities.clone(),
website_url: interface.website_url.clone(),
privacy_policy_url: interface.privacy_policy_url.clone(),
terms_of_service_url: interface.terms_of_service_url.clone(),
default_prompt: interface.default_prompt.clone(),
brand_color: interface.brand_color.clone(),
composer_icon: resolve_interface_asset_path(
plugin_root,
"interface.composerIcon",
interface.composer_icon.as_deref(),
),
logo: resolve_interface_asset_path(
plugin_root,
"interface.logo",
interface.logo.as_deref(),
),
screenshots: interface
.screenshots
.iter()
.filter_map(|screenshot| {
resolve_interface_asset_path(plugin_root, "interface.screenshots", Some(screenshot))
})
.collect(),
};
let has_fields = interface.display_name.is_some()
|| interface.short_description.is_some()
|| interface.long_description.is_some()
|| interface.developer_name.is_some()
|| interface.category.is_some()
|| !interface.capabilities.is_empty()
|| interface.website_url.is_some()
|| interface.privacy_policy_url.is_some()
|| interface.terms_of_service_url.is_some()
|| interface.default_prompt.is_some()
|| interface.brand_color.is_some()
|| interface.composer_icon.is_some()
|| interface.logo.is_some()
|| !interface.screenshots.is_empty();
has_fields.then_some(interface)
}
pub(crate) fn plugin_manifest_paths(
manifest: &PluginManifest,
plugin_root: &Path,
) -> PluginManifestPaths {
PluginManifestPaths {
skills: resolve_manifest_path(plugin_root, "skills", manifest.skills.as_deref()),
mcp_servers: resolve_manifest_path(
plugin_root,
"mcpServers",
manifest.mcp_servers.as_deref(),
),
apps: resolve_manifest_path(plugin_root, "apps", manifest.apps.as_deref()),
}
}
fn resolve_interface_asset_path(
plugin_root: &Path,
field: &'static str,
path: Option<&str>,
) -> Option<AbsolutePathBuf> {
resolve_manifest_path(plugin_root, field, path)
}
fn resolve_manifest_path(
plugin_root: &Path,
field: &'static str,
path: Option<&str>,
) -> Option<AbsolutePathBuf> {
// `plugin.json` paths are required to be relative to the plugin root and we return the
// normalized absolute path to the rest of the system.
let path = path?;
if path.is_empty() {
return None;
}
let Some(relative_path) = path.strip_prefix("./") else {
tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root");
return None;
};
if relative_path.is_empty() {
tracing::warn!("ignoring {field}: path must not be `./`");
return None;
}
let mut normalized = std::path::PathBuf::new();
for component in Path::new(relative_path).components() {
match component {
Component::Normal(component) => normalized.push(component),
Component::ParentDir => {
tracing::warn!("ignoring {field}: path must not contain '..'");
return None;
}
_ => {
tracing::warn!("ignoring {field}: path must stay within the plugin root");
return None;
}
}
}
AbsolutePathBuf::try_from(plugin_root.join(normalized))
.map_err(|err| {
tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}");
err
})
.ok()
}
+240 -48
View File
@@ -1,3 +1,6 @@
use super::PluginManifestInterfaceSummary;
use super::load_plugin_manifest;
use super::plugin_manifest_interface;
use super::store::PluginId;
use super::store::PluginIdError;
use crate::git_info::get_git_repo_root;
@@ -21,7 +24,7 @@ pub struct ResolvedMarketplacePlugin {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceSummary {
pub name: String,
pub path: PathBuf,
pub path: AbsolutePathBuf,
pub plugins: Vec<MarketplacePluginSummary>,
}
@@ -29,11 +32,12 @@ pub struct MarketplaceSummary {
pub struct MarketplacePluginSummary {
pub name: String,
pub source: MarketplacePluginSourceSummary,
pub interface: Option<PluginManifestInterfaceSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarketplacePluginSourceSummary {
Local { path: PathBuf },
Local { path: AbsolutePathBuf },
}
#[derive(Debug, thiserror::Error)]
@@ -73,7 +77,7 @@ pub fn resolve_marketplace_plugin(
marketplace_path: &AbsolutePathBuf,
plugin_name: &str,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
let marketplace = load_marketplace(marketplace_path.as_path())?;
let marketplace = load_marketplace(marketplace_path)?;
let marketplace_name = marketplace.name;
let plugin = marketplace
.plugins
@@ -92,7 +96,7 @@ pub fn resolve_marketplace_plugin(
})?;
Ok(ResolvedMarketplacePlugin {
plugin_id,
source_path: resolve_plugin_source_path(marketplace_path.as_path(), plugin.source)?,
source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?,
})
}
@@ -109,23 +113,21 @@ fn list_marketplaces_with_home(
let mut marketplaces = Vec::new();
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
let marketplace = load_marketplace(marketplace_path.as_path())?;
let marketplace = load_marketplace(&marketplace_path)?;
let mut plugins = Vec::new();
for plugin in marketplace.plugins {
let source = match plugin.source {
MarketplacePluginSource::Local { path } => MarketplacePluginSourceSummary::Local {
path: resolve_plugin_source_path(
marketplace_path.as_path(),
MarketplacePluginSource::Local { path },
)?
.into_path_buf(),
},
let source_path = resolve_plugin_source_path(&marketplace_path, plugin.source)?;
let source = MarketplacePluginSourceSummary::Local {
path: source_path.clone(),
};
let interface = load_plugin_manifest(source_path.as_path())
.and_then(|manifest| plugin_manifest_interface(&manifest, source_path.as_path()));
plugins.push(MarketplacePluginSummary {
name: plugin.name,
source,
interface,
});
}
@@ -142,30 +144,34 @@ fn list_marketplaces_with_home(
fn discover_marketplace_paths_from_roots(
additional_roots: &[AbsolutePathBuf],
home_dir: Option<&Path>,
) -> Vec<PathBuf> {
) -> Vec<AbsolutePathBuf> {
let mut paths = Vec::new();
if let Some(home) = home_dir {
let path = home.join(MARKETPLACE_RELATIVE_PATH);
if path.is_file() {
if path.is_file()
&& let Ok(path) = AbsolutePathBuf::try_from(path)
{
paths.push(path);
}
}
for root in additional_roots {
if let Some(repo_root) = get_git_repo_root(root.as_path()) {
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
if path.is_file() && !paths.contains(&path) {
paths.push(path);
}
if let Some(repo_root) = get_git_repo_root(root.as_path())
&& let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root)
&& let Ok(path) = repo_root.join(MARKETPLACE_RELATIVE_PATH)
&& path.as_path().is_file()
&& !paths.contains(&path)
{
paths.push(path);
}
}
paths
}
fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
let contents = fs::read_to_string(path).map_err(|err| {
fn load_marketplace(path: &AbsolutePathBuf) -> Result<MarketplaceFile, MarketplaceError> {
let contents = fs::read_to_string(path.as_path()).map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
MarketplaceError::MarketplaceNotFound {
path: path.to_path_buf(),
@@ -181,7 +187,7 @@ fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
}
fn resolve_plugin_source_path(
marketplace_path: &Path,
marketplace_path: &AbsolutePathBuf,
source: MarketplacePluginSource,
) -> Result<AbsolutePathBuf, MarketplaceError> {
match source {
@@ -206,25 +212,61 @@ fn resolve_plugin_source_path(
{
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "local plugin source path must stay within the marketplace directory"
message: "local plugin source path must stay within the marketplace root"
.to_string(),
});
}
let source_path = marketplace_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(relative_source_path);
AbsolutePathBuf::try_from(source_path).map_err(|err| {
MarketplaceError::InvalidMarketplaceFile {
// `marketplace.json` lives under `<root>/.agents/plugins/`, but local plugin paths
// are resolved relative to `<root>`, not relative to the `plugins/` directory.
marketplace_root_dir(marketplace_path)?
.join(relative_source_path)
.map_err(|err| MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: format!("plugin source path must resolve to an absolute path: {err}"),
}
})
})
}
}
}
fn marketplace_root_dir(
marketplace_path: &AbsolutePathBuf,
) -> Result<AbsolutePathBuf, MarketplaceError> {
let Some(plugins_dir) = marketplace_path.parent() else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
};
let Some(dot_agents_dir) = plugins_dir.parent() else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
};
let Some(marketplace_root) = dot_agents_dir.parent() else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
};
if plugins_dir.as_path().file_name().and_then(|s| s.to_str()) != Some("plugins")
|| dot_agents_dir
.as_path()
.file_name()
.and_then(|s| s.to_str())
!= Some(".agents")
{
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
}
Ok(marketplace_root)
}
#[derive(Debug, Deserialize)]
struct MarketplaceFile {
name: String,
@@ -284,8 +326,7 @@ mod tests {
ResolvedMarketplacePlugin {
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
.unwrap(),
source_path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/plugin-1"))
.unwrap(),
source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
}
);
}
@@ -381,37 +422,51 @@ mod tests {
vec![
MarketplaceSummary {
name: "codex-curated".to_string(),
path: home_root.join(".agents/plugins/marketplace.json"),
path: AbsolutePathBuf::try_from(
home_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
plugins: vec![
MarketplacePluginSummary {
name: "shared-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-shared"),
path: AbsolutePathBuf::try_from(home_root.join("home-shared"))
.unwrap(),
},
interface: None,
},
MarketplacePluginSummary {
name: "home-only".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-only"),
path: AbsolutePathBuf::try_from(home_root.join("home-only"))
.unwrap(),
},
interface: None,
},
],
},
MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_root.join(".agents/plugins/marketplace.json"),
path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
plugins: vec![
MarketplacePluginSummary {
name: "shared-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-shared"),
path: AbsolutePathBuf::try_from(repo_root.join("repo-shared"))
.unwrap(),
},
interface: None,
},
MarketplacePluginSummary {
name: "repo-only".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-only"),
path: AbsolutePathBuf::try_from(repo_root.join("repo-only"))
.unwrap(),
},
interface: None,
},
],
},
@@ -475,22 +530,24 @@ mod tests {
vec![
MarketplaceSummary {
name: "codex-curated".to_string(),
path: home_marketplace,
path: AbsolutePathBuf::try_from(home_marketplace).unwrap(),
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-plugin"),
path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(),
},
interface: None,
}],
},
MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_marketplace.clone(),
path: AbsolutePathBuf::try_from(repo_marketplace.clone()).unwrap(),
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-plugin"),
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
},
interface: None,
}],
},
]
@@ -504,7 +561,7 @@ mod tests {
assert_eq!(
resolved.source_path,
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/repo-plugin")).unwrap()
AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap()
);
}
@@ -547,17 +604,152 @@ mod tests {
marketplaces,
vec![MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_root.join(".agents/plugins/marketplace.json"),
path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
.unwrap(),
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/plugin"),
path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(),
},
interface: None,
}],
}]
);
}
#[test]
fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("plugins/demo-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "demo-plugin",
"interface": {
"displayName": "Demo",
"capabilities": ["Interactive", "Write"],
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
"screenshots": ["./assets/shot1.png"]
}
}"#,
)
.unwrap();
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
assert_eq!(
marketplaces[0].plugins[0].interface,
Some(PluginManifestInterfaceSummary {
display_name: Some("Demo".to_string()),
short_description: None,
long_description: None,
developer_name: None,
category: None,
capabilities: vec!["Interactive".to_string(), "Write".to_string()],
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: Some(
AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(),
),
logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()),
screenshots: vec![
AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(),
],
})
);
}
#[test]
fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("plugins/demo-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "demo-plugin",
"interface": {
"displayName": "Demo",
"capabilities": ["Interactive"],
"composerIcon": "assets/icon.png",
"logo": "/tmp/logo.png",
"screenshots": ["assets/shot1.png"]
}
}"#,
)
.unwrap();
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
assert_eq!(
marketplaces[0].plugins[0].interface,
Some(PluginManifestInterfaceSummary {
display_name: Some("Demo".to_string()),
short_description: None,
long_description: None,
developer_name: None,
category: None,
capabilities: vec!["Interactive".to_string()],
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: None,
logo: None,
screenshots: Vec::new(),
})
);
}
#[test]
fn resolve_marketplace_plugin_rejects_non_relative_local_paths() {
let tmp = tempdir().unwrap();
@@ -634,7 +826,7 @@ mod tests {
assert_eq!(
resolved.source_path,
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/first")).unwrap()
AbsolutePathBuf::try_from(repo_root.join("first")).unwrap()
);
}
}
+7
View File
@@ -1,3 +1,4 @@
mod curated_repo;
mod injection;
mod manager;
mod manifest;
@@ -5,6 +6,8 @@ mod marketplace;
mod render;
mod store;
pub(crate) use curated_repo::curated_plugins_repo_path;
pub(crate) use curated_repo::sync_openai_plugins_repo;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplacePluginSummary;
@@ -17,8 +20,12 @@ pub use manager::PluginLoadOutcome;
pub use manager::PluginsManager;
pub use manager::load_plugin_apps;
pub(crate) use manager::plugin_namespace_for_skill_path;
pub use manifest::PluginManifestInterfaceSummary;
pub(crate) use manifest::PluginManifestPaths;
pub(crate) use manifest::load_plugin_manifest;
pub(crate) use manifest::plugin_manifest_interface;
pub(crate) use manifest::plugin_manifest_name;
pub(crate) use manifest::plugin_manifest_paths;
pub use marketplace::MarketplaceError;
pub use marketplace::MarketplacePluginSourceSummary;
pub(crate) use render::render_explicit_plugin_instructions;
+29 -16
View File
@@ -61,7 +61,7 @@ impl PluginId {
pub struct PluginInstallResult {
pub plugin_id: PluginId,
pub plugin_version: String,
pub installed_path: PathBuf,
pub installed_path: AbsolutePathBuf,
}
#[derive(Debug, Clone)]
@@ -92,19 +92,25 @@ impl PluginStore {
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
}
pub fn is_installed(&self, plugin_id: &PluginId) -> bool {
self.plugin_root(plugin_id, DEFAULT_PLUGIN_VERSION)
.as_path()
.is_dir()
}
pub fn install(
&self,
source_path: PathBuf,
source_path: AbsolutePathBuf,
plugin_id: PluginId,
) -> Result<PluginInstallResult, PluginStoreError> {
if !source_path.is_dir() {
if !source_path.as_path().is_dir() {
return Err(PluginStoreError::Invalid(format!(
"plugin source path is not a directory: {}",
source_path.display()
)));
}
let plugin_name = plugin_name_for_source(&source_path)?;
let plugin_name = plugin_name_for_source(source_path.as_path())?;
if plugin_name != plugin_id.plugin_name {
return Err(PluginStoreError::Invalid(format!(
"plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`",
@@ -112,18 +118,16 @@ impl PluginStore {
)));
}
let plugin_version = DEFAULT_PLUGIN_VERSION.to_string();
let installed_path = self
.plugin_root(&plugin_id, &plugin_version)
.into_path_buf();
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
if let Some(parent) = installed_path.parent() {
fs::create_dir_all(parent).map_err(|err| {
fs::create_dir_all(parent.as_path()).map_err(|err| {
PluginStoreError::io("failed to create plugin cache directory", err)
})?;
}
remove_existing_target(&installed_path)?;
copy_dir_recursive(&source_path, &installed_path)?;
remove_existing_target(installed_path.as_path())?;
copy_dir_recursive(source_path.as_path(), installed_path.as_path())?;
Ok(PluginInstallResult {
plugin_id,
@@ -257,7 +261,10 @@ mod tests {
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(tmp.path().join("sample-plugin"), plugin_id.clone())
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id.clone(),
)
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
@@ -266,7 +273,7 @@ mod tests {
PluginInstallResult {
plugin_id,
plugin_version: "local".to_string(),
installed_path: installed_path.clone(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
@@ -280,7 +287,10 @@ mod tests {
let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(tmp.path().join("source-dir"), plugin_id.clone())
.install(
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
plugin_id.clone(),
)
.unwrap();
assert_eq!(
@@ -288,7 +298,10 @@ mod tests {
PluginInstallResult {
plugin_id,
plugin_version: "local".to_string(),
installed_path: tmp.path().join("plugins/cache/market/manifest-name/local"),
installed_path: AbsolutePathBuf::try_from(
tmp.path().join("plugins/cache/market/manifest-name/local"),
)
.unwrap(),
}
);
}
@@ -327,7 +340,7 @@ mod tests {
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
tmp.path().join("source-dir"),
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();
@@ -355,7 +368,7 @@ mod tests {
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
tmp.path().join("source-dir"),
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();
+13 -7
View File
@@ -284,13 +284,19 @@ pub(crate) async fn handle_start(
.experimental_realtime_ws_backend_prompt
.clone()
.unwrap_or(params.prompt);
let prompt =
match build_realtime_startup_context(sess.as_ref(), REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET)
.await
{
Some(context) => format!("{prompt}\n\n{context}"),
None => prompt,
};
let startup_context = match config.experimental_realtime_ws_startup_context.clone() {
Some(startup_context) => startup_context,
None => {
build_realtime_startup_context(sess.as_ref(), REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET)
.await
.unwrap_or_default()
}
};
let prompt = if startup_context.is_empty() {
prompt
} else {
format!("{prompt}\n\n{startup_context}")
};
let model = config.experimental_realtime_ws_model.clone();
let requested_session_id = params
+3 -3
View File
@@ -7,7 +7,7 @@ use chrono::DateTime;
use chrono::NaiveDateTime;
use chrono::Timelike;
use chrono::Utc;
use codex_otel::OtelManager;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::RolloutItem;
@@ -96,7 +96,7 @@ pub(crate) fn builder_from_items(
pub(crate) async fn extract_metadata_from_rollout(
rollout_path: &Path,
default_provider: &str,
otel: Option<&OtelManager>,
otel: Option<&SessionTelemetry>,
) -> anyhow::Result<ExtractionOutcome> {
let (items, _thread_id, parse_errors) =
RolloutRecorder::load_rollout_items(rollout_path).await?;
@@ -144,7 +144,7 @@ pub(crate) async fn extract_metadata_from_rollout(
pub(crate) async fn backfill_sessions(
runtime: &codex_state::StateRuntime,
config: &Config,
otel: Option<&OtelManager>,
otel: Option<&SessionTelemetry>,
) {
let timer = otel.and_then(|otel| otel.start_timer(DB_METRIC_BACKFILL_DURATION_MS, &[]).ok());
let backfill_state = match runtime.get_backfill_state().await {
+466 -34
View File
@@ -30,6 +30,14 @@ use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
pub use codex_protocol::models::SandboxPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize;
@@ -62,6 +70,8 @@ pub struct ExecRequest {
pub windows_sandbox_level: WindowsSandboxLevel,
pub sandbox_permissions: SandboxPermissions,
pub sandbox_policy: SandboxPolicy,
pub file_system_sandbox_policy: FileSystemSandboxPolicy,
pub network_sandbox_policy: NetworkSandboxPolicy,
pub justification: Option<String>,
pub arg0: Option<String>,
}
@@ -72,6 +82,8 @@ pub struct ExecRequest {
pub(crate) struct SandboxTransformRequest<'a> {
pub spec: CommandSpec,
pub policy: &'a SandboxPolicy,
pub file_system_policy: &'a FileSystemSandboxPolicy,
pub network_policy: NetworkSandboxPolicy,
pub sandbox: SandboxType,
pub enforce_managed_network: bool,
// TODO(viyatb): Evaluate switching this to Option<Arc<NetworkProxy>>
@@ -203,6 +215,41 @@ fn additional_permission_roots(
)
}
#[cfg_attr(not(test), allow(dead_code))]
fn merge_file_system_policy_with_additional_permissions(
file_system_policy: &FileSystemSandboxPolicy,
extra_reads: Vec<AbsolutePathBuf>,
extra_writes: Vec<AbsolutePathBuf>,
) -> FileSystemSandboxPolicy {
match file_system_policy.kind {
FileSystemSandboxKind::Restricted => {
let mut merged_policy = file_system_policy.clone();
for path in extra_reads {
let entry = FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
};
if !merged_policy.entries.contains(&entry) {
merged_policy.entries.push(entry);
}
}
for path in extra_writes {
let entry = FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Write,
};
if !merged_policy.entries.contains(&entry) {
merged_policy.entries.push(entry);
}
}
merged_policy
}
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
file_system_policy.clone()
}
}
}
fn merge_read_only_access_with_additional_reads(
read_only_access: &ReadOnlyAccess,
extra_reads: Vec<AbsolutePathBuf>,
@@ -246,9 +293,17 @@ fn sandbox_policy_with_additional_permissions(
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions);
match sandbox_policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
sandbox_policy.clone()
}
SandboxPolicy::DangerFullAccess => SandboxPolicy::DangerFullAccess,
SandboxPolicy::ExternalSandbox { network_access } => SandboxPolicy::ExternalSandbox {
network_access: if merge_network_access(
network_access.is_enabled(),
additional_permissions,
) {
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
},
},
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access,
@@ -297,6 +352,35 @@ fn sandbox_policy_with_additional_permissions(
}
}
pub(crate) fn should_require_platform_sandbox(
file_system_policy: &FileSystemSandboxPolicy,
network_policy: NetworkSandboxPolicy,
has_managed_network_requirements: bool,
) -> bool {
if has_managed_network_requirements {
return true;
}
if !network_policy.is_enabled() {
return !matches!(
file_system_policy.kind,
FileSystemSandboxKind::ExternalSandbox
);
}
match file_system_policy.kind {
FileSystemSandboxKind::Restricted => !file_system_policy.entries.iter().any(|entry| {
entry.access == FileSystemAccessMode::Write
&& matches!(
&entry.path,
FileSystemPath::Special { value }
if matches!(value, FileSystemSpecialPath::Root)
)
}),
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => false,
}
}
#[derive(Default)]
pub struct SandboxManager;
@@ -307,7 +391,8 @@ impl SandboxManager {
pub(crate) fn select_initial(
&self,
policy: &SandboxPolicy,
file_system_policy: &FileSystemSandboxPolicy,
network_policy: NetworkSandboxPolicy,
pref: SandboxablePreference,
windows_sandbox_level: WindowsSandboxLevel,
has_managed_network_requirements: bool,
@@ -322,22 +407,20 @@ impl SandboxManager {
)
.unwrap_or(SandboxType::None)
}
SandboxablePreference::Auto => match policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
if has_managed_network_requirements {
crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
SandboxablePreference::Auto => {
if should_require_platform_sandbox(
file_system_policy,
network_policy,
has_managed_network_requirements,
) {
crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
_ => crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None),
},
}
}
}
@@ -348,6 +431,8 @@ impl SandboxManager {
let SandboxTransformRequest {
mut spec,
policy,
file_system_policy,
network_policy,
sandbox,
enforce_managed_network,
network,
@@ -360,16 +445,41 @@ impl SandboxManager {
} = request;
#[cfg(not(target_os = "macos"))]
let macos_seatbelt_profile_extensions = None;
let effective_permissions = EffectiveSandboxPermissions::new(
let additional_permissions = spec.additional_permissions.take();
let EffectiveSandboxPermissions {
sandbox_policy: effective_policy,
macos_seatbelt_profile_extensions: _effective_macos_seatbelt_profile_extensions,
} = EffectiveSandboxPermissions::new(
policy,
macos_seatbelt_profile_extensions,
spec.additional_permissions.as_ref(),
additional_permissions.as_ref(),
);
let (effective_file_system_policy, effective_network_policy) =
if let Some(additional_permissions) = additional_permissions {
let (extra_reads, extra_writes) =
additional_permission_roots(&additional_permissions);
let file_system_sandbox_policy =
if extra_reads.is_empty() && extra_writes.is_empty() {
file_system_policy.clone()
} else {
merge_file_system_policy_with_additional_permissions(
file_system_policy,
extra_reads,
extra_writes,
)
};
let network_sandbox_policy =
if merge_network_access(network_policy.is_enabled(), &additional_permissions) {
NetworkSandboxPolicy::Enabled
} else {
NetworkSandboxPolicy::Restricted
};
(file_system_sandbox_policy, network_sandbox_policy)
} else {
(file_system_policy.clone(), network_policy)
};
let mut env = spec.env;
if !effective_permissions
.sandbox_policy
.has_full_network_access()
{
if !effective_network_policy.is_enabled() {
env.insert(
CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(),
"1".to_string(),
@@ -388,13 +498,11 @@ impl SandboxManager {
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
let mut args = create_seatbelt_command_args_with_extensions(
command.clone(),
&effective_permissions.sandbox_policy,
&effective_policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
effective_permissions
.macos_seatbelt_profile_extensions
.as_ref(),
_effective_macos_seatbelt_profile_extensions.as_ref(),
);
let mut full_command = Vec::with_capacity(1 + args.len());
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
@@ -409,7 +517,7 @@ impl SandboxManager {
let allow_proxy_network = allow_network_for_proxy(enforce_managed_network);
let mut args = create_linux_sandbox_command_args(
command.clone(),
&effective_permissions.sandbox_policy,
&effective_policy,
sandbox_policy_cwd,
use_linux_sandbox_bwrap,
allow_proxy_network,
@@ -444,7 +552,9 @@ impl SandboxManager {
sandbox,
windows_sandbox_level,
sandbox_permissions: spec.sandbox_permissions,
sandbox_policy: effective_permissions.sandbox_policy,
sandbox_policy: effective_policy,
file_system_sandbox_policy: effective_file_system_policy,
network_sandbox_policy: effective_network_policy,
justification: spec.justification,
arg0: arg0_override,
})
@@ -477,9 +587,12 @@ mod tests {
#[cfg(target_os = "macos")]
use super::EffectiveSandboxPermissions;
use super::SandboxManager;
use super::merge_file_system_policy_with_additional_permissions;
use super::normalize_additional_permissions;
use super::sandbox_policy_with_additional_permissions;
use super::should_require_platform_sandbox;
use crate::exec::SandboxType;
use crate::protocol::NetworkAccess;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
use crate::tools::sandboxing::SandboxablePreference;
@@ -493,16 +606,24 @@ mod tests {
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use tempfile::TempDir;
#[test]
fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() {
let manager = SandboxManager::new();
let sandbox = manager.select_initial(
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
false,
@@ -515,7 +636,8 @@ mod tests {
let manager = SandboxManager::new();
let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None);
let sandbox = manager.select_initial(
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
true,
@@ -523,6 +645,98 @@ mod tests {
assert_eq!(sandbox, expected);
}
#[test]
fn restricted_file_system_uses_platform_sandbox_without_managed_network() {
let manager = SandboxManager::new();
let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None);
let sandbox = manager.select_initial(
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}]),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
false,
);
assert_eq!(sandbox, expected);
}
#[test]
fn full_access_restricted_policy_skips_platform_sandbox_when_network_is_enabled() {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}]);
assert_eq!(
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false),
false
);
}
#[test]
fn full_access_restricted_policy_still_uses_platform_sandbox_for_restricted_network() {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}]);
assert_eq!(
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Restricted, false),
true
);
}
#[test]
fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() {
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let exec_request = manager
.transform(super::SandboxTransformRequest {
spec: super::CommandSpec {
program: "true".to_string(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
expiration: crate::exec::ExecExpiration::DefaultTimeout,
sandbox_permissions: super::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
},
policy: &SandboxPolicy::ExternalSandbox {
network_access: crate::protocol::NetworkAccess::Restricted,
},
file_system_policy: &FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: None,
use_linux_sandbox_bwrap: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.expect("transform");
assert_eq!(
exec_request.file_system_sandbox_policy,
FileSystemSandboxPolicy::unrestricted()
);
assert_eq!(
exec_request.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
}
#[test]
fn normalize_additional_permissions_preserves_network() {
let temp_dir = TempDir::new().expect("create temp dir");
@@ -624,7 +838,6 @@ mod tests {
}
);
}
#[cfg(target_os = "macos")]
#[test]
fn effective_permissions_merge_macos_extensions_with_additional_permissions() {
@@ -679,4 +892,223 @@ mod tests {
})
);
}
#[test]
fn external_sandbox_additional_permissions_can_enable_network() {
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let policy = sandbox_policy_with_additional_permissions(
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
&PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![path]),
write: Some(Vec::new()),
}),
..Default::default()
},
);
assert_eq!(
policy,
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
}
);
}
#[test]
fn transform_additional_permissions_enable_network_for_external_sandbox() {
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let exec_request = manager
.transform(super::SandboxTransformRequest {
spec: super::CommandSpec {
program: "true".to_string(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
expiration: crate::exec::ExecExpiration::DefaultTimeout,
sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions,
additional_permissions: Some(PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![path]),
write: Some(Vec::new()),
}),
..Default::default()
}),
justification: None,
},
policy: &SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
file_system_policy: &FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: None,
use_linux_sandbox_bwrap: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.expect("transform");
assert_eq!(
exec_request.sandbox_policy,
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
}
);
assert_eq!(
exec_request.network_sandbox_policy,
NetworkSandboxPolicy::Enabled
);
}
#[test]
fn transform_additional_permissions_preserves_denied_entries() {
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let temp_dir = TempDir::new().expect("create temp dir");
let workspace_root = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let allowed_path = workspace_root.join("allowed").expect("allowed path");
let denied_path = workspace_root.join("denied").expect("denied path");
let exec_request = manager
.transform(super::SandboxTransformRequest {
spec: super::CommandSpec {
program: "true".to_string(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
expiration: crate::exec::ExecExpiration::DefaultTimeout,
sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions,
additional_permissions: Some(PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![allowed_path.clone()]),
}),
..Default::default()
}),
justification: None,
},
policy: &SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
},
file_system_policy: &FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_path.clone(),
},
access: FileSystemAccessMode::None,
},
]),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: None,
use_linux_sandbox_bwrap: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.expect("transform");
assert_eq!(
exec_request.file_system_sandbox_policy,
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: denied_path },
access: FileSystemAccessMode::None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: allowed_path },
access: FileSystemAccessMode::Write,
},
])
);
assert_eq!(
exec_request.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
}
#[test]
fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roots() {
let temp_dir = TempDir::new().expect("create temp dir");
let cwd = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let allowed_path = cwd.join("allowed").expect("allowed path");
let denied_path = cwd.join("denied").expect("denied path");
let merged_policy = merge_file_system_policy_with_additional_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_path.clone(),
},
access: FileSystemAccessMode::None,
},
]),
vec![allowed_path.clone()],
Vec::new(),
);
assert_eq!(
merged_policy.entries.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path { path: denied_path },
access: FileSystemAccessMode::None,
}),
true
);
assert_eq!(
merged_policy.entries.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path { path: allowed_path },
access: FileSystemAccessMode::Read,
}),
true
);
}
}

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