app-server: Add streaming and tty/pty capabilities to command/exec (#13640)

* Add an ability to stream stdin, stdout, and stderr
* Streaming of stdout and stderr has a configurable cap for total amount
of transmitted bytes (with an ability to disable it)
* Add support for overriding environment variables
* Add an ability to terminate running applications (using
`command/exec/terminate`)
* Add TTY/PTY support, with an ability to resize the terminal (using
`command/exec/resize`)
This commit is contained in:
Ruslan Nigmatullin 2026-03-06 17:30:17 -08:00 committed by GitHub
parent 61098c7f51
commit e9bd8b20a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 4205 additions and 70 deletions

1
codex-rs/Cargo.lock generated
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",

View file

@ -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": {
@ -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": {

View file

@ -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": {

View file

@ -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": {
@ -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": {
@ -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": {

View file

@ -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": {
@ -10720,6 +11022,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": {

View file

@ -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"
}

View file

@ -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": [

View file

@ -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"
}

View file

@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/resize`.",
"title": "CommandExecResizeResponse",
"type": "object"
}

View file

@ -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"
}
},

View file

@ -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"
}

View file

@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/terminate`.",
"title": "CommandExecTerminateResponse",
"type": "object"
}

View file

@ -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"
}

View file

@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Empty success response for `command/exec/write`.",
"title": "CommandExecWriteResponse",
"type": "object"
}

View file

@ -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, };

View file

@ -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 };

View file

@ -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, };

View file

@ -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";

View file

@ -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, };

View file

@ -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, };

View file

@ -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>;

View file

@ -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, };

View file

@ -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, };

View file

@ -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, };

View file

@ -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>;

View file

@ -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, };

View file

@ -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>;

View file

@ -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";

View file

@ -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),

View file

@ -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),
}
}

View file

@ -1805,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(
@ -3989,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/")]
@ -4971,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 {

View file

@ -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",

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 }

View file

@ -144,6 +144,10 @@ Example with notification opt-out:
- `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`.
- `review/start` — kick off Codexs 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.
@ -161,7 +165,6 @@ Example with notification opt-out:
- `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 servers 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

View file

@ -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;
@ -193,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;
@ -264,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;
@ -282,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;
@ -369,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,
@ -482,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,
@ -831,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 { .. } => {
@ -1503,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() {
@ -1535,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()
@ -1549,7 +1656,7 @@ impl CodexMessageProcessor {
arg0: None,
};
let requested_policy = params.sandbox_policy.map(|policy| policy.to_core());
let requested_policy = sandbox_policy.map(|policy| policy.to_core());
let effective_policy = match requested_policy {
Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) {
Ok(()) => policy,
@ -1568,41 +1675,100 @@ impl CodexMessageProcessor {
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,
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) {
@ -2872,6 +3038,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;

File diff suppressed because it is too large Load diff

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;

View file

@ -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,

View file

@ -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())
}

View file

@ -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

View file

@ -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;

View file

@ -34,6 +34,7 @@ use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use crate::text_encoding::bytes_to_string_smart;
use codex_network_proxy::NetworkProxy;
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 +54,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>,
@ -157,6 +167,27 @@ pub async fn process_exec_tool_call(
use_linux_sandbox_bwrap: bool,
stdout_stream: Option<StdoutStream>,
) -> Result<ExecToolCallOutput> {
let exec_req = build_exec_request(
params,
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,
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 {
@ -226,9 +257,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(
@ -796,16 +825,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;

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;