Compare commits

...

7 Commits

Author SHA1 Message Date
Ruslan Nigmatullin
0628035988 cr 2026-03-06 13:25:07 -08:00
Ruslan Nigmatullin
627a5510a0 app-server: add streaming command/exec contract and implementation
Define the v2 command/exec contract and wire it through app-server and core in one slice: processId, env overrides, timeout and output-cap controls, streaming notifications, and PTY write/resize/terminate support.

Keep the generated schema, README updates, backend plumbing, and test harness changes together so reviewers can read the API and runtime behavior in the same commit.
2026-03-06 13:25:06 -08:00
Ruslan Nigmatullin
893c5252ce Merge branch 'main' into ruslan/pty-and-streaming 2026-03-06 12:16:39 -08:00
Ruslan Nigmatullin
f319913d25 rm extra unused attrs 2026-03-06 12:06:23 -08:00
Ruslan Nigmatullin
10f9cdec01 Merge branch 'main' of github.com:openai/codex into ruslan/pty-and-streaming 2026-03-06 11:58:35 -08:00
Ruslan Nigmatullin
9dbf1a14d3 cr 2026-03-06 11:43:18 -08:00
Ruslan Nigmatullin
f59dcafb49 utils/pty: add streaming spawn and terminal sizing primitives
Split stdout and stderr routing into reusable output sinks, expose explicit streaming spawn helpers, and thread terminal sizing through the shared PTY layer.

This keeps the legacy spawn helpers intact so existing callers only need mechanical call-site updates while app-server gains the lower-level primitives it needs for interactive command execution.
2026-03-05 23:57:16 -08:00
53 changed files with 4480 additions and 174 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": {
@@ -10591,6 +10893,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

@@ -1804,29 +1804,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(
@@ -3965,6 +4120,26 @@ pub struct CommandExecutionOutputDeltaNotification {
pub delta: String,
}
/// Base64-encoded output chunk emitted for a streaming `command/exec` request.
///
/// These notifications are connection-scoped. If the originating connection
/// closes, the server terminates the process.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecOutputDeltaNotification {
/// Client-supplied, connection-scoped `processId` from the original
/// `command/exec` request.
pub process_id: String,
/// Output stream for this chunk.
pub stream: CommandExecOutputStream,
/// Base64-encoded output bytes.
pub delta_base64: String,
/// `true` on the final streamed chunk for a stream when `outputBytesCap`
/// truncated later output on that stream.
pub cap_reached: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -4947,6 +5122,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;
@@ -192,6 +196,7 @@ use codex_core::connectors::filter_disallowed_connectors;
use codex_core::connectors::merge_plugin_apps;
use codex_core::default_client::set_default_client_residency_requirement;
use codex_core::error::CodexErr;
use codex_core::exec::ExecExpiration;
use codex_core::exec::ExecParams;
use codex_core::exec_env::create_env;
use codex_core::features::FEATURES;
@@ -263,6 +268,7 @@ use codex_state::StateRuntime;
use codex_state::ThreadMetadataBuilder;
use codex_state::log_db::LogDbLayer;
use codex_utils_json_to_toml::json_to_toml;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
@@ -281,6 +287,7 @@ use tokio::sync::Mutex;
use tokio::sync::broadcast;
use tokio::sync::oneshot;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use toml::Value as TomlValue;
use tracing::error;
use tracing::info;
@@ -368,6 +375,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,
@@ -466,6 +474,7 @@ impl CodexMessageProcessor {
pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())),
thread_state_manager: ThreadStateManager::new(),
thread_watch_manager: ThreadWatchManager::new_with_outgoing(outgoing),
command_exec_manager: CommandExecManager::default(),
pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())),
fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())),
feedback,
@@ -815,6 +824,18 @@ impl CodexMessageProcessor {
self.exec_one_off_command(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::CommandExecWrite { request_id, params } => {
self.command_exec_write(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::CommandExecResize { request_id, params } => {
self.command_exec_resize(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::CommandExecTerminate { request_id, params } => {
self.command_exec_terminate(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ConfigRead { .. }
| ClientRequest::ConfigValueWrite { .. }
| ClientRequest::ConfigBatchWrite { .. } => {
@@ -1487,11 +1508,84 @@ impl CodexMessageProcessor {
return;
}
let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone());
let env = create_env(&self.config.permissions.shell_environment_policy, None);
let timeout_ms = params
.timeout_ms
.and_then(|timeout_ms| u64::try_from(timeout_ms).ok());
let CommandExecParams {
command,
process_id,
tty,
stream_stdin,
stream_stdout_stderr,
output_bytes_cap,
disable_output_cap,
disable_timeout,
timeout_ms,
cwd,
env: env_overrides,
size,
sandbox_policy,
} = params;
if size.is_some() && !tty {
let error = JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: "command/exec size requires tty: true".to_string(),
data: None,
};
self.outgoing.send_error(request, error).await;
return;
}
if disable_output_cap && output_bytes_cap.is_some() {
let error = JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: "command/exec cannot set both outputBytesCap and disableOutputCap"
.to_string(),
data: None,
};
self.outgoing.send_error(request, error).await;
return;
}
if disable_timeout && timeout_ms.is_some() {
let error = JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: "command/exec cannot set both timeoutMs and disableTimeout".to_string(),
data: None,
};
self.outgoing.send_error(request, error).await;
return;
}
let cwd = cwd.unwrap_or_else(|| self.config.cwd.clone());
let mut env = create_env(&self.config.permissions.shell_environment_policy, None);
if let Some(env_overrides) = env_overrides {
for (key, value) in env_overrides {
match value {
Some(value) => {
env.insert(key, value);
}
None => {
env.remove(&key);
}
}
}
}
let timeout_ms = match timeout_ms {
Some(timeout_ms) => match u64::try_from(timeout_ms) {
Ok(timeout_ms) => Some(timeout_ms),
Err(_) => {
let error = JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: format!(
"command/exec timeoutMs must be non-negative, got {timeout_ms}"
),
data: None,
};
self.outgoing.send_error(request, error).await;
return;
}
},
None => None,
};
let managed_network_requirements_enabled =
self.config.managed_network_requirements_enabled();
let started_network_proxy = match self.config.permissions.network.as_ref() {
@@ -1519,10 +1613,23 @@ impl CodexMessageProcessor {
None => None,
};
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
let output_bytes_cap = if disable_output_cap {
None
} else {
Some(output_bytes_cap.unwrap_or(DEFAULT_OUTPUT_BYTES_CAP))
};
let expiration = if disable_timeout {
ExecExpiration::Cancellation(CancellationToken::new())
} else {
match timeout_ms {
Some(timeout_ms) => timeout_ms.into(),
None => ExecExpiration::DefaultTimeout,
}
};
let exec_params = ExecParams {
command: params.command,
command,
cwd,
expiration: timeout_ms.into(),
expiration,
env,
network: started_network_proxy
.as_ref()
@@ -1533,7 +1640,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,
@@ -1552,41 +1659,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) {
@@ -2856,6 +3022,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,839 @@
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_delta = read_command_exec_delta(&mut mcp).await?;
assert_eq!(started_delta.process_id, process_id.as_str());
assert_eq!(started_delta.stream, CommandExecOutputStream::Stdout);
assert!(
String::from_utf8(STANDARD.decode(&started_delta.delta_base64)?)?
.replace('\r', "")
.contains("tty\n"),
"expected TTY startup output, got {started_delta:?}"
);
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_delta = read_command_exec_delta(&mut mcp).await?;
assert_eq!(echoed_delta.process_id, process_id.as_str());
assert_eq!(echoed_delta.stream, CommandExecOutputStream::Stdout);
assert!(
String::from_utf8(STANDARD.decode(&echoed_delta.delta_base64)?)?
.replace('\r', "")
.contains("echo:world\n"),
"expected TTY echo output, got {echoed_delta:?}"
);
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_delta = read_command_exec_delta(&mut mcp).await?;
let started_text =
String::from_utf8(STANDARD.decode(&started_delta.delta_base64)?)?.replace('\r', "");
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_delta = read_command_exec_delta(&mut mcp).await?;
let resized_text =
String::from_utf8(STANDARD.decode(&resized_delta.delta_base64)?)?.replace('\r', "");
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_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;

View File

@@ -5,6 +5,7 @@ use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use tokio::sync::Mutex;
use tokio::sync::Notify;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot::error::TryRecvError;
use tokio::task::JoinHandle;
@@ -47,6 +48,7 @@ pub(crate) struct OutputHandles {
#[derive(Debug)]
pub(crate) struct UnifiedExecProcess {
process_handle: ExecCommandSession,
output_rx: broadcast::Receiver<Vec<u8>>,
output_buffer: OutputBuffer,
output_notify: Arc<Notify>,
output_closed: Arc<AtomicBool>,
@@ -72,6 +74,7 @@ impl UnifiedExecProcess {
let cancellation_token = CancellationToken::new();
let output_drained = Arc::new(Notify::new());
let mut receiver = initial_output_rx;
let output_rx = receiver.resubscribe();
let buffer_clone = Arc::clone(&output_buffer);
let notify_clone = Arc::clone(&output_notify);
let output_closed_clone = Arc::clone(&output_closed);
@@ -97,6 +100,7 @@ impl UnifiedExecProcess {
Self {
process_handle,
output_rx,
output_buffer,
output_notify,
output_closed,
@@ -124,7 +128,7 @@ impl UnifiedExecProcess {
}
pub(super) fn output_receiver(&self) -> tokio::sync::broadcast::Receiver<Vec<u8>> {
self.process_handle.output_receiver()
self.output_rx.resubscribe()
}
pub(super) fn cancellation_token(&self) -> CancellationToken {
@@ -214,9 +218,11 @@ impl UnifiedExecProcess {
) -> Result<Self, UnifiedExecError> {
let SpawnedPty {
session: process_handle,
output_rx,
stdout_rx,
stderr_rx,
mut exit_rx,
} = spawned;
let output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx);
let managed = Self::new(process_handle, output_rx, sandbox_type, spawn_lifecycle);
let exit_ready = matches!(exit_rx.try_recv(), Ok(_) | Err(TryRecvError::Closed));

View File

@@ -543,6 +543,7 @@ impl UnifiedExecProcessManager {
env.cwd.as_path(),
&env.env,
&env.arg0,
codex_utils_pty::TerminalSize::default(),
)
.await
} else {

View File

@@ -124,13 +124,20 @@ trust_level = "trusted"
&repo_root,
&env,
&None,
codex_utils_pty::TerminalSize::default(),
)
.await?;
let mut output = Vec::new();
let mut output_rx = spawned.output_rx;
let mut exit_rx = spawned.exit_rx;
let writer_tx = spawned.session.writer_sender();
let codex_utils_pty::SpawnedProcess {
session,
stdout_rx,
stderr_rx,
exit_rx,
} = spawned;
let mut output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx);
let mut exit_rx = exit_rx;
let writer_tx = session.writer_sender();
let interrupt_writer = writer_tx.clone();
let interrupt_task = tokio::spawn(async move {
sleep(Duration::from_secs(2)).await;
@@ -165,7 +172,7 @@ trust_level = "trusted"
Ok(Ok(code)) => code,
Ok(Err(err)) => return Err(err.into()),
Err(_) => {
spawned.session.terminate();
session.terminate();
anyhow::bail!("timed out waiting for codex resume to exit");
}
};

View File

@@ -71,12 +71,19 @@ async fn run_codex_cli(
cwd.as_ref(),
&env,
&None,
codex_utils_pty::TerminalSize::default(),
)
.await?;
let mut output = Vec::new();
let mut output_rx = spawned.output_rx;
let mut exit_rx = spawned.exit_rx;
let writer_tx = spawned.session.writer_sender();
let codex_utils_pty::SpawnedProcess {
session,
stdout_rx,
stderr_rx,
exit_rx,
} = spawned;
let mut output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx);
let mut exit_rx = exit_rx;
let writer_tx = session.writer_sender();
let exit_code_result = timeout(Duration::from_secs(10), async {
// Read PTY output until the process exits while replying to cursor
// position queries so the TUI can initialize without a real terminal.
@@ -103,7 +110,7 @@ async fn run_codex_cli(
Ok(Ok(code)) => code,
Ok(Err(err)) => return Err(err.into()),
Err(_) => {
spawned.session.terminate();
session.terminate();
anyhow::bail!("timed out waiting for codex CLI to exit");
}
};

View File

@@ -4,22 +4,27 @@ Lightweight helpers for spawning interactive processes either under a PTY (pseud
## API surface
- `spawn_pty_process(program, args, cwd, env, arg0)``SpawnedProcess`
- `spawn_pty_process(program, args, cwd, env, arg0, size)``SpawnedProcess`
- `spawn_pipe_process(program, args, cwd, env, arg0)``SpawnedProcess`
- `spawn_pipe_process_no_stdin(program, args, cwd, env, arg0)``SpawnedProcess`
- `combine_output_receivers(stdout_rx, stderr_rx)``broadcast::Receiver<Vec<u8>>`
- `conpty_supported()``bool` (Windows only; always true elsewhere)
- `TerminalSize { rows, cols }` selects PTY dimensions in character cells.
- `ProcessHandle` exposes:
- `writer_sender()``mpsc::Sender<Vec<u8>>` (stdin)
- `output_receiver()``broadcast::Receiver<Vec<u8>>` (stdout/stderr merged)
- `resize(TerminalSize)`
- `close_stdin()`
- `has_exited()`, `exit_code()`, `terminate()`
- `SpawnedProcess` bundles `handle`, `output_rx`, and `exit_rx` (oneshot exit code).
- `SpawnedProcess` bundles `session`, `stdout_rx`, `stderr_rx`, and `exit_rx` (oneshot exit code).
## Usage examples
```rust
use std::collections::HashMap;
use std::path::Path;
use codex_utils_pty::combine_output_receivers;
use codex_utils_pty::spawn_pty_process;
use codex_utils_pty::TerminalSize;
# tokio_test::block_on(async {
let env_map: HashMap<String, String> = std::env::vars().collect();
@@ -29,13 +34,14 @@ let spawned = spawn_pty_process(
Path::new("."),
&env_map,
&None,
TerminalSize::default(),
).await?;
let writer = spawned.session.writer_sender();
writer.send(b"exit\n".to_vec()).await?;
// Collect output until the process exits.
let mut output_rx = spawned.output_rx;
let mut output_rx = combine_output_receivers(spawned.stdout_rx, spawned.stderr_rx);
let mut collected = Vec::new();
while let Ok(chunk) = output_rx.try_recv() {
collected.extend_from_slice(&chunk);

View File

@@ -7,14 +7,20 @@ mod tests;
#[cfg(windows)]
mod win;
pub const DEFAULT_OUTPUT_BYTES_CAP: usize = 1024 * 1024;
/// Spawn a non-interactive process using regular pipes for stdin/stdout/stderr.
pub use pipe::spawn_process as spawn_pipe_process;
/// Spawn a non-interactive process using regular pipes, but close stdin immediately.
pub use pipe::spawn_process_no_stdin as spawn_pipe_process_no_stdin;
/// Combine stdout/stderr receivers into a single broadcast receiver.
pub use process::combine_output_receivers;
/// Handle for interacting with a spawned process (PTY or pipe).
pub use process::ProcessHandle;
/// Bundle of process handles plus output and exit receivers returned by spawn helpers.
/// Bundle of process handles plus split output and exit receivers returned by spawn helpers.
pub use process::SpawnedProcess;
/// Terminal size in character cells used for PTY spawn and resize operations.
pub use process::TerminalSize;
/// Backwards-compatible alias for ProcessHandle.
pub type ExecCommandSession = ProcessHandle;
/// Backwards-compatible alias for SpawnedProcess.

View File

@@ -13,7 +13,6 @@ use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::process::Command;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
@@ -73,7 +72,7 @@ fn kill_process(pid: u32) -> io::Result<()> {
}
}
async fn read_output_stream<R>(mut reader: R, output_tx: broadcast::Sender<Vec<u8>>)
async fn read_output_stream<R>(mut reader: R, output_tx: mpsc::Sender<Vec<u8>>)
where
R: AsyncRead + Unpin,
{
@@ -82,7 +81,7 @@ where
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
let _ = output_tx.send(buf[..n].to_vec());
let _ = output_tx.send(buf[..n].to_vec()).await;
}
Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
@@ -157,9 +156,8 @@ async fn spawn_process_with_stdin_mode(
let stderr = child.stderr.take();
let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
let (output_tx, _) = broadcast::channel::<Vec<u8>>(256);
let initial_output_rx = output_tx.subscribe();
let (stdout_tx, stdout_rx) = mpsc::channel::<Vec<u8>>(128);
let (stderr_tx, stderr_rx) = mpsc::channel::<Vec<u8>>(128);
let writer_handle = if let Some(stdin) = stdin {
let writer = Arc::new(tokio::sync::Mutex::new(stdin));
tokio::spawn(async move {
@@ -175,15 +173,15 @@ async fn spawn_process_with_stdin_mode(
};
let stdout_handle = stdout.map(|stdout| {
let output_tx = output_tx.clone();
let stdout_tx = stdout_tx.clone();
tokio::spawn(async move {
read_output_stream(BufReader::new(stdout), output_tx).await;
read_output_stream(BufReader::new(stdout), stdout_tx).await;
})
});
let stderr_handle = stderr.map(|stderr| {
let output_tx = output_tx.clone();
let stderr_tx = stderr_tx.clone();
tokio::spawn(async move {
read_output_stream(BufReader::new(stderr), output_tx).await;
read_output_stream(BufReader::new(stderr), stderr_tx).await;
})
});
let mut reader_abort_handles = Vec::new();
@@ -219,10 +217,8 @@ async fn spawn_process_with_stdin_mode(
let _ = exit_tx.send(code);
});
let (handle, output_rx) = ProcessHandle::new(
let handle = ProcessHandle::new(
writer_tx,
output_tx,
initial_output_rx,
Box::new(PipeChildTerminator {
#[cfg(windows)]
pid,
@@ -240,12 +236,13 @@ async fn spawn_process_with_stdin_mode(
Ok(SpawnedProcess {
session: handle,
output_rx,
stdout_rx,
stderr_rx,
exit_rx,
})
}
/// Spawn a process using regular pipes (no PTY), returning handles for stdin, output, and exit.
/// Spawn a process using regular pipes (no PTY), returning handles for stdin, split output, and exit.
pub async fn spawn_process(
program: &str,
args: &[String],

View File

@@ -4,7 +4,9 @@ use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use anyhow::anyhow;
use portable_pty::MasterPty;
use portable_pty::PtySize;
use portable_pty::SlavePty;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
@@ -16,6 +18,29 @@ pub(crate) trait ChildTerminator: Send + Sync {
fn kill(&mut self) -> io::Result<()>;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TerminalSize {
pub rows: u16,
pub cols: u16,
}
impl Default for TerminalSize {
fn default() -> Self {
Self { rows: 24, cols: 80 }
}
}
impl From<TerminalSize> for PtySize {
fn from(value: TerminalSize) -> Self {
Self {
rows: value.rows,
cols: value.cols,
pixel_width: 0,
pixel_height: 0,
}
}
}
pub struct PtyHandles {
pub _slave: Option<Box<dyn SlavePty + Send>>,
pub _master: Box<dyn MasterPty + Send>,
@@ -29,8 +54,7 @@ impl fmt::Debug for PtyHandles {
/// Handle for driving an interactive process (PTY or pipe).
pub struct ProcessHandle {
writer_tx: mpsc::Sender<Vec<u8>>,
output_tx: broadcast::Sender<Vec<u8>>,
writer_tx: StdMutex<Option<mpsc::Sender<Vec<u8>>>>,
killer: StdMutex<Option<Box<dyn ChildTerminator>>>,
reader_handle: StdMutex<Option<JoinHandle<()>>>,
reader_abort_handles: StdMutex<Vec<AbortHandle>>,
@@ -53,8 +77,6 @@ impl ProcessHandle {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
writer_tx: mpsc::Sender<Vec<u8>>,
output_tx: broadcast::Sender<Vec<u8>>,
initial_output_rx: broadcast::Receiver<Vec<u8>>,
killer: Box<dyn ChildTerminator>,
reader_handle: JoinHandle<()>,
reader_abort_handles: Vec<AbortHandle>,
@@ -63,32 +85,31 @@ impl ProcessHandle {
exit_status: Arc<AtomicBool>,
exit_code: Arc<StdMutex<Option<i32>>>,
pty_handles: Option<PtyHandles>,
) -> (Self, broadcast::Receiver<Vec<u8>>) {
(
Self {
writer_tx,
output_tx,
killer: StdMutex::new(Some(killer)),
reader_handle: StdMutex::new(Some(reader_handle)),
reader_abort_handles: StdMutex::new(reader_abort_handles),
writer_handle: StdMutex::new(Some(writer_handle)),
wait_handle: StdMutex::new(Some(wait_handle)),
exit_status,
exit_code,
_pty_handles: StdMutex::new(pty_handles),
},
initial_output_rx,
)
) -> Self {
Self {
writer_tx: StdMutex::new(Some(writer_tx)),
killer: StdMutex::new(Some(killer)),
reader_handle: StdMutex::new(Some(reader_handle)),
reader_abort_handles: StdMutex::new(reader_abort_handles),
writer_handle: StdMutex::new(Some(writer_handle)),
wait_handle: StdMutex::new(Some(wait_handle)),
exit_status,
exit_code,
_pty_handles: StdMutex::new(pty_handles),
}
}
/// Returns a channel sender for writing raw bytes to the child stdin.
pub fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
self.writer_tx.clone()
}
if let Ok(writer_tx) = self.writer_tx.lock() {
if let Some(writer_tx) = writer_tx.as_ref() {
return writer_tx.clone();
}
}
/// Returns a broadcast receiver that yields stdout/stderr chunks.
pub fn output_receiver(&self) -> broadcast::Receiver<Vec<u8>> {
self.output_tx.subscribe()
let (writer_tx, writer_rx) = mpsc::channel(1);
drop(writer_rx);
writer_tx
}
/// True if the child process has exited.
@@ -101,13 +122,38 @@ impl ProcessHandle {
self.exit_code.lock().ok().and_then(|guard| *guard)
}
/// Attempts to kill the child and abort helper tasks.
pub fn terminate(&self) {
/// Resize the PTY in character cells.
pub fn resize(&self, size: TerminalSize) -> anyhow::Result<()> {
let handles = self
._pty_handles
.lock()
.map_err(|_| anyhow!("failed to lock PTY handles"))?;
let handles = handles
.as_ref()
.ok_or_else(|| anyhow!("process is not attached to a PTY"))?;
handles._master.resize(size.into())
}
/// Close the child's stdin channel.
pub fn close_stdin(&self) {
if let Ok(mut writer_tx) = self.writer_tx.lock() {
writer_tx.take();
}
}
/// Attempts to kill the child while leaving the reader/writer tasks alive
/// so callers can still drain output until EOF.
pub fn request_terminate(&self) {
if let Ok(mut killer_opt) = self.killer.lock() {
if let Some(mut killer) = killer_opt.take() {
let _ = killer.kill();
}
}
}
/// Attempts to kill the child and abort helper tasks.
pub fn terminate(&self) {
self.request_terminate();
if let Ok(mut h) = self.reader_handle.lock() {
if let Some(handle) = h.take() {
@@ -138,10 +184,46 @@ impl Drop for ProcessHandle {
}
}
/// Return value from spawn helpers (PTY or pipe).
/// Combine split stdout/stderr receivers into a single broadcast receiver.
pub fn combine_output_receivers(
mut stdout_rx: mpsc::Receiver<Vec<u8>>,
mut stderr_rx: mpsc::Receiver<Vec<u8>>,
) -> broadcast::Receiver<Vec<u8>> {
let (combined_tx, combined_rx) = broadcast::channel(256);
tokio::spawn(async move {
let mut stdout_open = true;
let mut stderr_open = true;
loop {
tokio::select! {
stdout = stdout_rx.recv(), if stdout_open => match stdout {
Some(chunk) => {
let _ = combined_tx.send(chunk);
}
None => {
stdout_open = false;
}
},
stderr = stderr_rx.recv(), if stderr_open => match stderr {
Some(chunk) => {
let _ = combined_tx.send(chunk);
}
None => {
stderr_open = false;
}
},
else => break,
}
}
});
combined_rx
}
/// Return value from PTY or pipe spawn helpers.
#[derive(Debug)]
pub struct SpawnedProcess {
pub session: ProcessHandle,
pub output_rx: broadcast::Receiver<Vec<u8>>,
pub stdout_rx: mpsc::Receiver<Vec<u8>>,
pub stderr_rx: mpsc::Receiver<Vec<u8>>,
pub exit_rx: oneshot::Receiver<i32>,
}

View File

@@ -10,8 +10,6 @@ use anyhow::Result;
#[cfg(not(windows))]
use portable_pty::native_pty_system;
use portable_pty::CommandBuilder;
use portable_pty::PtySize;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
@@ -20,6 +18,7 @@ use crate::process::ChildTerminator;
use crate::process::ProcessHandle;
use crate::process::PtyHandles;
use crate::process::SpawnedProcess;
use crate::process::TerminalSize;
/// Returns true when ConPTY support is available (Windows only).
#[cfg(windows)]
@@ -72,25 +71,21 @@ fn platform_native_pty_system() -> Box<dyn portable_pty::PtySystem + Send> {
}
}
/// Spawn a process attached to a PTY, returning handles for stdin, output, and exit.
/// Spawn a process attached to a PTY, returning handles for stdin, split output, and exit.
pub async fn spawn_process(
program: &str,
args: &[String],
cwd: &Path,
env: &HashMap<String, String>,
arg0: &Option<String>,
size: TerminalSize,
) -> Result<SpawnedProcess> {
if program.is_empty() {
anyhow::bail!("missing program for PTY spawn");
}
let pty_system = platform_native_pty_system();
let pair = pty_system.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})?;
let pair = pty_system.openpty(size.into())?;
let mut command_builder = CommandBuilder::new(arg0.as_ref().unwrap_or(&program.to_string()));
command_builder.cwd(cwd);
@@ -111,18 +106,16 @@ pub async fn spawn_process(
let killer = child.clone_killer();
let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
let (output_tx, _) = broadcast::channel::<Vec<u8>>(256);
let initial_output_rx = output_tx.subscribe();
let (stdout_tx, stdout_rx) = mpsc::channel::<Vec<u8>>(128);
let (_stderr_tx, stderr_rx) = mpsc::channel::<Vec<u8>>(1);
let mut reader = pair.master.try_clone_reader()?;
let output_tx_clone = output_tx.clone();
let reader_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 8_192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = output_tx_clone.send(buf[..n].to_vec());
let _ = stdout_tx.blocking_send(buf[..n].to_vec());
}
Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
Err(ref e) if e.kind() == ErrorKind::WouldBlock => {
@@ -174,10 +167,8 @@ pub async fn spawn_process(
_master: pair.master,
};
let (handle, output_rx) = ProcessHandle::new(
let handle = ProcessHandle::new(
writer_tx,
output_tx,
initial_output_rx,
Box::new(PtyChildTerminator {
killer,
#[cfg(unix)]
@@ -194,7 +185,8 @@ pub async fn spawn_process(
Ok(SpawnedProcess {
session: handle,
output_rx,
stdout_rx,
stderr_rx,
exit_rx,
})
}

View File

@@ -3,8 +3,12 @@ use std::path::Path;
use pretty_assertions::assert_eq;
use crate::combine_output_receivers;
use crate::spawn_pipe_process;
use crate::spawn_pipe_process_no_stdin;
use crate::spawn_pty_process;
use crate::SpawnedProcess;
use crate::TerminalSize;
fn find_python() -> Option<String> {
for candidate in ["python3", "python"] {
@@ -51,6 +55,38 @@ fn echo_sleep_command(marker: &str) -> String {
}
}
fn split_stdout_stderr_command() -> String {
"printf 'split-out\\n'; printf 'split-err\\n' >&2".to_string()
}
async fn collect_split_output(mut output_rx: tokio::sync::mpsc::Receiver<Vec<u8>>) -> Vec<u8> {
let mut collected = Vec::new();
while let Some(chunk) = output_rx.recv().await {
collected.extend_from_slice(&chunk);
}
collected
}
fn combine_spawned_output(
spawned: SpawnedProcess,
) -> (
crate::ProcessHandle,
tokio::sync::broadcast::Receiver<Vec<u8>>,
tokio::sync::oneshot::Receiver<i32>,
) {
let SpawnedProcess {
session,
stdout_rx,
stderr_rx,
exit_rx,
} = spawned;
(
session,
combine_output_receivers(stdout_rx, stderr_rx),
exit_rx,
)
}
async fn collect_output_until_exit(
mut output_rx: tokio::sync::broadcast::Receiver<Vec<u8>>,
exit_rx: tokio::sync::oneshot::Receiver<i32>,
@@ -219,9 +255,17 @@ async fn pty_python_repl_emits_output_and_exits() -> anyhow::Result<()> {
};
let env_map: HashMap<String, String> = std::env::vars().collect();
let spawned = spawn_pty_process(&python, &[], Path::new("."), &env_map, &None).await?;
let writer = spawned.session.writer_sender();
let mut output_rx = spawned.output_rx;
let spawned = spawn_pty_process(
&python,
&[],
Path::new("."),
&env_map,
&None,
TerminalSize::default(),
)
.await?;
let (session, mut output_rx, exit_rx) = combine_spawned_output(spawned);
let writer = session.writer_sender();
let newline = if cfg!(windows) { "\r\n" } else { "\n" };
let startup_timeout_ms = if cfg!(windows) { 10_000 } else { 5_000 };
let mut output =
@@ -232,8 +276,7 @@ async fn pty_python_repl_emits_output_and_exits() -> anyhow::Result<()> {
writer.send(format!("exit(){newline}").into_bytes()).await?;
let timeout_ms = if cfg!(windows) { 10_000 } else { 5_000 };
let (remaining_output, code) =
collect_output_until_exit(output_rx, spawned.exit_rx, timeout_ms).await;
let (remaining_output, code) = collect_output_until_exit(output_rx, exit_rx, timeout_ms).await;
output.extend_from_slice(&remaining_output);
let text = String::from_utf8_lossy(&output);
@@ -260,10 +303,11 @@ async fn pipe_process_round_trips_stdin() -> anyhow::Result<()> {
];
let env_map: HashMap<String, String> = std::env::vars().collect();
let spawned = spawn_pipe_process(&python, &args, Path::new("."), &env_map, &None).await?;
let writer = spawned.session.writer_sender();
let (session, output_rx, exit_rx) = combine_spawned_output(spawned);
let writer = session.writer_sender();
writer.send(b"roundtrip\n".to_vec()).await?;
let (output, code) = collect_output_until_exit(spawned.output_rx, spawned.exit_rx, 5_000).await;
let (output, code) = collect_output_until_exit(output_rx, exit_rx, 5_000).await;
let text = String::from_utf8_lossy(&output);
assert!(
@@ -288,7 +332,7 @@ async fn pipe_process_detaches_from_parent_session() -> anyhow::Result<()> {
let (program, args) = shell_command(script);
let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None).await?;
let mut output_rx = spawned.output_rx;
let (_session, mut output_rx, exit_rx) = combine_spawned_output(spawned);
let pid_bytes =
tokio::time::timeout(tokio::time::Duration::from_millis(500), output_rx.recv()).await??;
let pid_text = String::from_utf8_lossy(&pid_bytes);
@@ -309,7 +353,7 @@ async fn pipe_process_detaches_from_parent_session() -> anyhow::Result<()> {
"expected child to be detached from parent session"
);
let exit_code = spawned.exit_rx.await.unwrap_or(-1);
let exit_code = exit_rx.await.unwrap_or(-1);
assert_eq!(
exit_code, 0,
"expected detached pipe process to exit cleanly"
@@ -327,13 +371,23 @@ async fn pipe_and_pty_share_interface() -> anyhow::Result<()> {
let pipe =
spawn_pipe_process(&pipe_program, &pipe_args, Path::new("."), &env_map, &None).await?;
let pty = spawn_pty_process(&pty_program, &pty_args, Path::new("."), &env_map, &None).await?;
let pty = spawn_pty_process(
&pty_program,
&pty_args,
Path::new("."),
&env_map,
&None,
TerminalSize::default(),
)
.await?;
let (_pipe_session, pipe_output_rx, pipe_exit_rx) = combine_spawned_output(pipe);
let (_pty_session, pty_output_rx, pty_exit_rx) = combine_spawned_output(pty);
let timeout_ms = if cfg!(windows) { 10_000 } else { 3_000 };
let (pipe_out, pipe_code) =
collect_output_until_exit(pipe.output_rx, pipe.exit_rx, timeout_ms).await;
collect_output_until_exit(pipe_output_rx, pipe_exit_rx, timeout_ms).await;
let (pty_out, pty_code) =
collect_output_until_exit(pty.output_rx, pty.exit_rx, timeout_ms).await;
collect_output_until_exit(pty_output_rx, pty_exit_rx, timeout_ms).await;
assert_eq!(pipe_code, 0);
assert_eq!(pty_code, 0);
@@ -360,9 +414,9 @@ async fn pipe_drains_stderr_without_stdout_activity() -> anyhow::Result<()> {
let args = vec!["-c".to_string(), script.to_string()];
let env_map: HashMap<String, String> = std::env::vars().collect();
let spawned = spawn_pipe_process(&python, &args, Path::new("."), &env_map, &None).await?;
let (_session, output_rx, exit_rx) = combine_spawned_output(spawned);
let (output, code) =
collect_output_until_exit(spawned.output_rx, spawned.exit_rx, 10_000).await;
let (output, code) = collect_output_until_exit(output_rx, exit_rx, 10_000).await;
assert_eq!(code, 0, "expected python to exit cleanly");
assert!(!output.is_empty(), "expected stderr output to be drained");
@@ -370,6 +424,53 @@ async fn pipe_drains_stderr_without_stdout_activity() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pipe_process_can_expose_split_stdout_and_stderr() -> anyhow::Result<()> {
let env_map: HashMap<String, String> = std::env::vars().collect();
let (program, args) = if cfg!(windows) {
let Some(python) = find_python() else {
eprintln!("python not found; skipping pipe_process_can_expose_split_stdout_and_stderr");
return Ok(());
};
(
python,
vec![
"-c".to_string(),
"import sys; sys.stdout.buffer.write(b'split-out\\n'); sys.stdout.buffer.flush(); sys.stderr.buffer.write(b'split-err\\n'); sys.stderr.buffer.flush()".to_string(),
],
)
} else {
shell_command(&split_stdout_stderr_command())
};
let spawned =
spawn_pipe_process_no_stdin(&program, &args, Path::new("."), &env_map, &None).await?;
let SpawnedProcess {
session: _session,
stdout_rx,
stderr_rx,
exit_rx,
} = spawned;
let stdout_task = tokio::spawn(async move { collect_split_output(stdout_rx).await });
let stderr_task = tokio::spawn(async move { collect_split_output(stderr_rx).await });
let code = tokio::time::timeout(tokio::time::Duration::from_secs(2), exit_rx)
.await
.map_err(|_| anyhow::anyhow!("timed out waiting for split process exit"))?
.unwrap_or(-1);
let stdout = tokio::time::timeout(tokio::time::Duration::from_secs(2), stdout_task)
.await
.map_err(|_| anyhow::anyhow!("timed out waiting to drain split stdout"))??;
let stderr = tokio::time::timeout(tokio::time::Duration::from_secs(2), stderr_task)
.await
.map_err(|_| anyhow::anyhow!("timed out waiting to drain split stderr"))??;
assert_eq!(stdout, b"split-out\n".to_vec());
assert_eq!(stderr, b"split-err\n".to_vec());
assert_eq!(code, 0);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pipe_terminate_aborts_detached_readers() -> anyhow::Result<()> {
if !setsid_available() {
@@ -381,17 +482,15 @@ async fn pipe_terminate_aborts_detached_readers() -> anyhow::Result<()> {
let script =
"setsid sh -c 'i=0; while [ $i -lt 200 ]; do echo tick; sleep 0.01; i=$((i+1)); done' &";
let (program, args) = shell_command(script);
let mut spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None).await?;
let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None).await?;
let (session, mut output_rx, _exit_rx) = combine_spawned_output(spawned);
let _ = tokio::time::timeout(
tokio::time::Duration::from_millis(500),
spawned.output_rx.recv(),
)
.await
.map_err(|_| anyhow::anyhow!("expected detached output before terminate"))??;
let _ = tokio::time::timeout(tokio::time::Duration::from_millis(500), output_rx.recv())
.await
.map_err(|_| anyhow::anyhow!("expected detached output before terminate"))??;
spawned.session.terminate();
let mut post_rx = spawned.session.output_receiver();
session.terminate();
let mut post_rx = output_rx.resubscribe();
let post_terminate =
tokio::time::timeout(tokio::time::Duration::from_millis(200), post_rx.recv()).await;
@@ -416,12 +515,21 @@ async fn pty_terminate_kills_background_children_in_same_process_group() -> anyh
let marker = "__codex_bg_pid:";
let script = format!("sleep 1000 & bg=$!; echo {marker}$bg; wait");
let (program, args) = shell_command(&script);
let mut spawned = spawn_pty_process(&program, &args, Path::new("."), &env_map, &None).await?;
let spawned = spawn_pty_process(
&program,
&args,
Path::new("."),
&env_map,
&None,
TerminalSize::default(),
)
.await?;
let (session, mut output_rx, _exit_rx) = combine_spawned_output(spawned);
let bg_pid = match wait_for_marker_pid(&mut spawned.output_rx, marker, 2_000).await {
let bg_pid = match wait_for_marker_pid(&mut output_rx, marker, 2_000).await {
Ok(pid) => pid,
Err(err) => {
spawned.session.terminate();
session.terminate();
return Err(err);
}
};
@@ -430,7 +538,7 @@ async fn pty_terminate_kills_background_children_in_same_process_group() -> anyh
"expected background child pid {bg_pid} to exist before terminate"
);
spawned.session.terminate();
session.terminate();
let exited = wait_for_process_exit(bg_pid, 3_000).await?;
if !exited {