mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Add unsandboxed process exec API (#19040)
## Why App-server clients sometimes need argv-based local process execution while sandbox policy is controlled outside Codex. Those environments can reject sandbox-disabling paths before a command ever starts, even when the caller intentionally wants unsandboxed execution. This PR adds a distinct `process/*` API for that use case instead of extending `command/exec` with another sandbox-disabling shape. Keeping the new surface separate also makes the future removal of `command/exec` simpler: clients that need explicit process lifecycle control can move to the newer handle-based API without depending on `command/exec` business logic. ## What changed - Added v2 process lifecycle methods: `process/spawn`, `process/writeStdin`, `process/resizePty`, and `process/kill`. - Added process notifications: `process/outputDelta` for streamed stdout/stderr chunks and `process/exited` for final exit status and buffered output. - Made `process/spawn` intentionally unsandboxed and omitted sandbox-selection fields such as `sandboxPolicy` and `permissionProfile`. - Added client-supplied, connection-scoped `processHandle` values for follow-up control requests and notification routing. - Supported cwd, environment overrides, PTY mode and size, stdin streaming, stdout/stderr streaming, per-stream output caps, and timeout controls. - Killed active process sessions when the originating app-server connection closes. - Wired the implementation through the modular `request_processors/` app-server layout, with process-handle request serialization for follow-up control calls. - Updated generated JSON/TypeScript schema fixtures and documented the new API in `codex-rs/app-server/README.md`. - Added v2 app-server integration coverage in `codex-rs/app-server/tests/suite/v2/process_exec.rs` for spawn acknowledgement before exit, buffered output caps, and process termination. ## Verification - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-app-server` --------- Co-authored-by: Owen Lin <owen@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
a8db4af5c3
commit
4950e7d8a6
@@ -2265,6 +2265,28 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProcessTerminalSize": {
|
||||
"description": "PTY size in character cells for `process/spawn` 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"
|
||||
},
|
||||
"RealtimeOutputModality": {
|
||||
"enum": [
|
||||
"text",
|
||||
|
||||
@@ -2415,6 +2415,96 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ProcessExitedNotification": {
|
||||
"description": "Final process exit notification for `process/spawn`.",
|
||||
"properties": {
|
||||
"exitCode": {
|
||||
"description": "Process exit code.",
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"processHandle": {
|
||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stderr": {
|
||||
"description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stderrCapReached": {
|
||||
"description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"stdout": {
|
||||
"description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stdoutCapReached": {
|
||||
"description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exitCode",
|
||||
"processHandle",
|
||||
"stderr",
|
||||
"stderrCapReached",
|
||||
"stdout",
|
||||
"stdoutCapReached"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProcessOutputDeltaNotification": {
|
||||
"description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.",
|
||||
"properties": {
|
||||
"capReached": {
|
||||
"description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deltaBase64": {
|
||||
"description": "Base64-encoded output bytes.",
|
||||
"type": "string"
|
||||
},
|
||||
"processHandle": {
|
||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stream": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ProcessOutputStream"
|
||||
}
|
||||
],
|
||||
"description": "Output stream this chunk belongs to."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"capReached",
|
||||
"deltaBase64",
|
||||
"processHandle",
|
||||
"stream"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProcessOutputStream": {
|
||||
"description": "Stream label for `process/outputDelta` notifications.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "stdout stream. PTY mode multiplexes terminal output here.",
|
||||
"enum": [
|
||||
"stdout"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "stderr stream.",
|
||||
"enum": [
|
||||
"stderr"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"RateLimitReachedType": {
|
||||
"enum": [
|
||||
"rate_limit_reached",
|
||||
@@ -5163,6 +5253,48 @@
|
||||
"title": "Command/exec/outputDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.",
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"process/outputDelta"
|
||||
],
|
||||
"title": "Process/outputDeltaNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ProcessOutputDeltaNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Process/outputDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Final exit notification for a `process/spawn` session.",
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"process/exited"
|
||||
],
|
||||
"title": "Process/exitedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ProcessExitedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Process/exitedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
+158
@@ -4248,6 +4248,48 @@
|
||||
"title": "Command/exec/outputDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.",
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"process/outputDelta"
|
||||
],
|
||||
"title": "Process/outputDeltaNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/ProcessOutputDeltaNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Process/outputDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Final exit notification for a `process/spawn` session.",
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"process/exited"
|
||||
],
|
||||
"title": "Process/exitedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/ProcessExitedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Process/exitedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -12655,6 +12697,122 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProcessExitedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Final process exit notification for `process/spawn`.",
|
||||
"properties": {
|
||||
"exitCode": {
|
||||
"description": "Process exit code.",
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"processHandle": {
|
||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stderr": {
|
||||
"description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stderrCapReached": {
|
||||
"description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"stdout": {
|
||||
"description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stdoutCapReached": {
|
||||
"description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exitCode",
|
||||
"processHandle",
|
||||
"stderr",
|
||||
"stderrCapReached",
|
||||
"stdout",
|
||||
"stdoutCapReached"
|
||||
],
|
||||
"title": "ProcessExitedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ProcessOutputDeltaNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.",
|
||||
"properties": {
|
||||
"capReached": {
|
||||
"description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deltaBase64": {
|
||||
"description": "Base64-encoded output bytes.",
|
||||
"type": "string"
|
||||
},
|
||||
"processHandle": {
|
||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stream": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/ProcessOutputStream"
|
||||
}
|
||||
],
|
||||
"description": "Output stream this chunk belongs to."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"capReached",
|
||||
"deltaBase64",
|
||||
"processHandle",
|
||||
"stream"
|
||||
],
|
||||
"title": "ProcessOutputDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ProcessOutputStream": {
|
||||
"description": "Stream label for `process/outputDelta` notifications.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "stdout stream. PTY mode multiplexes terminal output here.",
|
||||
"enum": [
|
||||
"stdout"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "stderr stream.",
|
||||
"enum": [
|
||||
"stderr"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ProcessTerminalSize": {
|
||||
"description": "PTY size in character cells for `process/spawn` 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"
|
||||
},
|
||||
"ProfileV2": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
|
||||
+158
@@ -9308,6 +9308,122 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProcessExitedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Final process exit notification for `process/spawn`.",
|
||||
"properties": {
|
||||
"exitCode": {
|
||||
"description": "Process exit code.",
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"processHandle": {
|
||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stderr": {
|
||||
"description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stderrCapReached": {
|
||||
"description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"stdout": {
|
||||
"description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stdoutCapReached": {
|
||||
"description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exitCode",
|
||||
"processHandle",
|
||||
"stderr",
|
||||
"stderrCapReached",
|
||||
"stdout",
|
||||
"stdoutCapReached"
|
||||
],
|
||||
"title": "ProcessExitedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ProcessOutputDeltaNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.",
|
||||
"properties": {
|
||||
"capReached": {
|
||||
"description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deltaBase64": {
|
||||
"description": "Base64-encoded output bytes.",
|
||||
"type": "string"
|
||||
},
|
||||
"processHandle": {
|
||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stream": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ProcessOutputStream"
|
||||
}
|
||||
],
|
||||
"description": "Output stream this chunk belongs to."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"capReached",
|
||||
"deltaBase64",
|
||||
"processHandle",
|
||||
"stream"
|
||||
],
|
||||
"title": "ProcessOutputDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ProcessOutputStream": {
|
||||
"description": "Stream label for `process/outputDelta` notifications.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "stdout stream. PTY mode multiplexes terminal output here.",
|
||||
"enum": [
|
||||
"stdout"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "stderr stream.",
|
||||
"enum": [
|
||||
"stderr"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ProcessTerminalSize": {
|
||||
"description": "PTY size in character cells for `process/spawn` 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"
|
||||
},
|
||||
"ProfileV2": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
@@ -11386,6 +11502,48 @@
|
||||
"title": "Command/exec/outputDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.",
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"process/outputDelta"
|
||||
],
|
||||
"title": "Process/outputDeltaNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ProcessOutputDeltaNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Process/outputDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Final exit notification for a `process/spawn` session.",
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"process/exited"
|
||||
],
|
||||
"title": "Process/exitedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ProcessExitedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Process/exitedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Final process exit notification for `process/spawn`.",
|
||||
"properties": {
|
||||
"exitCode": {
|
||||
"description": "Process exit code.",
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"processHandle": {
|
||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stderr": {
|
||||
"description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stderrCapReached": {
|
||||
"description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"stdout": {
|
||||
"description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stdoutCapReached": {
|
||||
"description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exitCode",
|
||||
"processHandle",
|
||||
"stderr",
|
||||
"stderrCapReached",
|
||||
"stdout",
|
||||
"stdoutCapReached"
|
||||
],
|
||||
"title": "ProcessExitedNotification",
|
||||
"type": "object"
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"ProcessOutputStream": {
|
||||
"description": "Stream label for `process/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 `process/spawn` request.",
|
||||
"properties": {
|
||||
"capReached": {
|
||||
"description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deltaBase64": {
|
||||
"description": "Base64-encoded output bytes.",
|
||||
"type": "string"
|
||||
},
|
||||
"processHandle": {
|
||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||
"type": "string"
|
||||
},
|
||||
"stream": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ProcessOutputStream"
|
||||
}
|
||||
],
|
||||
"description": "Output stream this chunk belongs to."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"capReached",
|
||||
"deltaBase64",
|
||||
"processHandle",
|
||||
"stream"
|
||||
],
|
||||
"title": "ProcessOutputDeltaNotification",
|
||||
"type": "object"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+42
@@ -0,0 +1,42 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Final process exit notification for `process/spawn`.
|
||||
*/
|
||||
export type ProcessExitedNotification = {
|
||||
/**
|
||||
* Client-supplied, connection-scoped `processHandle` from `process/spawn`.
|
||||
*/
|
||||
processHandle: string,
|
||||
/**
|
||||
* Process exit code.
|
||||
*/
|
||||
exitCode: number,
|
||||
/**
|
||||
* Buffered stdout capture.
|
||||
*
|
||||
* Empty when stdout was streamed via `process/outputDelta`.
|
||||
*/
|
||||
stdout: string,
|
||||
/**
|
||||
* Whether stdout reached `outputBytesCap`.
|
||||
*
|
||||
* In streaming mode, stdout is empty and cap state is also reported on the
|
||||
* final stdout `process/outputDelta` notification.
|
||||
*/
|
||||
stdoutCapReached: boolean,
|
||||
/**
|
||||
* Buffered stderr capture.
|
||||
*
|
||||
* Empty when stderr was streamed via `process/outputDelta`.
|
||||
*/
|
||||
stderr: string,
|
||||
/**
|
||||
* Whether stderr reached `outputBytesCap`.
|
||||
*
|
||||
* In streaming mode, stderr is empty and cap state is also reported on the
|
||||
* final stderr `process/outputDelta` notification.
|
||||
*/
|
||||
stderrCapReached: boolean, };
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// 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 { ProcessOutputStream } from "./ProcessOutputStream";
|
||||
|
||||
/**
|
||||
* Base64-encoded output chunk emitted for a streaming `process/spawn` request.
|
||||
*/
|
||||
export type ProcessOutputDeltaNotification = {
|
||||
/**
|
||||
* Client-supplied, connection-scoped `processHandle` from `process/spawn`.
|
||||
*/
|
||||
processHandle: string,
|
||||
/**
|
||||
* Output stream this chunk belongs to.
|
||||
*/
|
||||
stream: ProcessOutputStream,
|
||||
/**
|
||||
* Base64-encoded output bytes.
|
||||
*/
|
||||
deltaBase64: string,
|
||||
/**
|
||||
* True on the final streamed chunk for this stream when output was
|
||||
* truncated by `outputBytesCap`.
|
||||
*/
|
||||
capReached: boolean, };
|
||||
@@ -0,0 +1,8 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Stream label for `process/outputDelta` notifications.
|
||||
*/
|
||||
export type ProcessOutputStream = "stdout" | "stderr";
|
||||
@@ -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 `process/spawn` PTY sessions.
|
||||
*/
|
||||
export type ProcessTerminalSize = {
|
||||
/**
|
||||
* Terminal height in character cells.
|
||||
*/
|
||||
rows: number,
|
||||
/**
|
||||
* Terminal width in character cells.
|
||||
*/
|
||||
cols: number, };
|
||||
@@ -295,6 +295,10 @@ export type { PluginSummary } from "./PluginSummary";
|
||||
export type { PluginUninstallParams } from "./PluginUninstallParams";
|
||||
export type { PluginUninstallResponse } from "./PluginUninstallResponse";
|
||||
export type { PluginsMigration } from "./PluginsMigration";
|
||||
export type { ProcessExitedNotification } from "./ProcessExitedNotification";
|
||||
export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotification";
|
||||
export type { ProcessOutputStream } from "./ProcessOutputStream";
|
||||
export type { ProcessTerminalSize } from "./ProcessTerminalSize";
|
||||
export type { ProfileV2 } from "./ProfileV2";
|
||||
export type { RateLimitReachedType } from "./RateLimitReachedType";
|
||||
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
|
||||
|
||||
@@ -80,6 +80,7 @@ pub enum ClientRequestSerializationScope {
|
||||
Thread { thread_id: String },
|
||||
ThreadPath { path: PathBuf },
|
||||
CommandExecProcess { process_id: String },
|
||||
Process { process_handle: String },
|
||||
FuzzyFileSearchSession { session_id: String },
|
||||
FsWatch { watch_id: String },
|
||||
McpOauth { server_name: String },
|
||||
@@ -127,6 +128,11 @@ macro_rules! serialization_scope_expr {
|
||||
process_id: $actual_params.$field.clone(),
|
||||
})
|
||||
};
|
||||
($actual_params:ident, process_handle($params:ident . $field:ident)) => {
|
||||
Some(ClientRequestSerializationScope::Process {
|
||||
process_handle: $actual_params.$field.clone(),
|
||||
})
|
||||
};
|
||||
($actual_params:ident, fuzzy_session_id($params:ident . $field:ident)) => {
|
||||
Some(ClientRequestSerializationScope::FuzzyFileSearchSession {
|
||||
session_id: $actual_params.$field.clone(),
|
||||
@@ -900,6 +906,34 @@ client_request_definitions! {
|
||||
serialization: command_process_id(params.process_id),
|
||||
response: v2::CommandExecResizeResponse,
|
||||
},
|
||||
#[experimental("process/spawn")]
|
||||
/// Spawn a standalone process (argv vector) without a Codex sandbox.
|
||||
ProcessSpawn => "process/spawn" {
|
||||
params: v2::ProcessSpawnParams,
|
||||
serialization: process_handle(params.process_handle),
|
||||
response: v2::ProcessSpawnResponse,
|
||||
},
|
||||
#[experimental("process/writeStdin")]
|
||||
/// Write stdin bytes to a running `process/spawn` session or close stdin.
|
||||
ProcessWriteStdin => "process/writeStdin" {
|
||||
params: v2::ProcessWriteStdinParams,
|
||||
serialization: process_handle(params.process_handle),
|
||||
response: v2::ProcessWriteStdinResponse,
|
||||
},
|
||||
#[experimental("process/kill")]
|
||||
/// Terminate a running `process/spawn` session by client-supplied `processHandle`.
|
||||
ProcessKill => "process/kill" {
|
||||
params: v2::ProcessKillParams,
|
||||
serialization: process_handle(params.process_handle),
|
||||
response: v2::ProcessKillResponse,
|
||||
},
|
||||
#[experimental("process/resizePty")]
|
||||
/// Resize a running PTY-backed `process/spawn` session by client-supplied `processHandle`.
|
||||
ProcessResizePty => "process/resizePty" {
|
||||
params: v2::ProcessResizePtyParams,
|
||||
serialization: process_handle(params.process_handle),
|
||||
response: v2::ProcessResizePtyResponse,
|
||||
},
|
||||
|
||||
ConfigRead => "config/read" {
|
||||
params: v2::ConfigReadParams,
|
||||
@@ -1401,6 +1435,12 @@ server_notification_definitions! {
|
||||
PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification),
|
||||
/// Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.
|
||||
CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification),
|
||||
/// Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.
|
||||
#[experimental("process/outputDelta")]
|
||||
ProcessOutputDelta => "process/outputDelta" (v2::ProcessOutputDeltaNotification),
|
||||
/// Final exit notification for a `process/spawn` session.
|
||||
#[experimental("process/exited")]
|
||||
ProcessExited => "process/exited" (v2::ProcessExitedNotification),
|
||||
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
|
||||
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
|
||||
/// Deprecated legacy apply_patch output stream notification.
|
||||
|
||||
@@ -3558,6 +3558,204 @@ pub enum CommandExecOutputStream {
|
||||
Stderr,
|
||||
}
|
||||
|
||||
/// PTY size in character cells for `process/spawn` PTY sessions.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ProcessTerminalSize {
|
||||
/// Terminal height in character cells.
|
||||
pub rows: u16,
|
||||
/// Terminal width in character cells.
|
||||
pub cols: u16,
|
||||
}
|
||||
|
||||
/// Spawn a standalone process (argv vector) without a Codex sandbox on the host
|
||||
/// where the app server is running.
|
||||
///
|
||||
/// `process/spawn` returns after the process has started and the connection-scoped
|
||||
/// `processHandle` has been registered. Process output and exit are reported via
|
||||
/// `process/outputDelta` and `process/exited` notifications.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ProcessSpawnParams {
|
||||
/// Command argv vector. Empty arrays are rejected.
|
||||
pub command: Vec<String>,
|
||||
/// Client-supplied, connection-scoped process handle.
|
||||
///
|
||||
/// Duplicate active handles are rejected on the same connection. The same
|
||||
/// handle can be reused after the prior process exits.
|
||||
pub process_handle: String,
|
||||
/// Absolute working directory for the process.
|
||||
pub cwd: AbsolutePathBuf,
|
||||
/// Enable PTY mode.
|
||||
///
|
||||
/// This implies `streamStdin` and `streamStdoutStderr`.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub tty: bool,
|
||||
/// Allow follow-up `process/writeStdin` requests to write stdin bytes.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub stream_stdin: bool,
|
||||
/// Stream stdout/stderr via `process/outputDelta` notifications.
|
||||
///
|
||||
/// Streamed bytes are not duplicated into the `process/exited` notification.
|
||||
#[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. Set to `null` to disable the
|
||||
/// cap.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::serde_helpers::deserialize_double_option",
|
||||
serialize_with = "super::serde_helpers::serialize_double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
#[ts(type = "number | null")]
|
||||
#[ts(optional = nullable)]
|
||||
pub output_bytes_cap: Option<Option<usize>>,
|
||||
/// Optional timeout in milliseconds.
|
||||
///
|
||||
/// When omitted, the server default applies. Set to `null` to disable the
|
||||
/// timeout.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::serde_helpers::deserialize_double_option",
|
||||
serialize_with = "super::serde_helpers::serialize_double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
#[ts(type = "number | null")]
|
||||
#[ts(optional = nullable)]
|
||||
pub timeout_ms: Option<Option<i64>>,
|
||||
/// Optional environment overrides merged into the app-server process
|
||||
/// 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<ProcessTerminalSize>,
|
||||
}
|
||||
|
||||
/// Successful response for `process/spawn`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ProcessSpawnResponse {}
|
||||
|
||||
/// Write stdin bytes to a running `process/spawn` session, close stdin, or
|
||||
/// both.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ProcessWriteStdinParams {
|
||||
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
|
||||
pub process_handle: 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 `process/writeStdin`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ProcessWriteStdinResponse {}
|
||||
|
||||
/// Terminate a running `process/spawn` session.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ProcessKillParams {
|
||||
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
|
||||
pub process_handle: String,
|
||||
}
|
||||
|
||||
/// Empty success response for `process/kill`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ProcessKillResponse {}
|
||||
|
||||
/// Resize a running PTY-backed `process/spawn` session.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ProcessResizePtyParams {
|
||||
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
|
||||
pub process_handle: String,
|
||||
/// New PTY size in character cells.
|
||||
pub size: ProcessTerminalSize,
|
||||
}
|
||||
|
||||
/// Empty success response for `process/resizePty`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ProcessResizePtyResponse {}
|
||||
|
||||
/// Stream label for `process/outputDelta` notifications.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum ProcessOutputStream {
|
||||
/// stdout stream. PTY mode multiplexes terminal output here.
|
||||
Stdout,
|
||||
/// stderr stream.
|
||||
Stderr,
|
||||
}
|
||||
|
||||
/// Base64-encoded output chunk emitted for a streaming `process/spawn` request.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ProcessOutputDeltaNotification {
|
||||
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
|
||||
pub process_handle: String,
|
||||
/// Output stream this chunk belongs to.
|
||||
pub stream: ProcessOutputStream,
|
||||
/// Base64-encoded output bytes.
|
||||
pub delta_base64: String,
|
||||
/// True on the final streamed chunk for this stream when output was
|
||||
/// truncated by `outputBytesCap`.
|
||||
pub cap_reached: bool,
|
||||
}
|
||||
|
||||
/// Final process exit notification for `process/spawn`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ProcessExitedNotification {
|
||||
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
|
||||
pub process_handle: String,
|
||||
/// Process exit code.
|
||||
pub exit_code: i32,
|
||||
/// Buffered stdout capture.
|
||||
///
|
||||
/// Empty when stdout was streamed via `process/outputDelta`.
|
||||
pub stdout: String,
|
||||
/// Whether stdout reached `outputBytesCap`.
|
||||
///
|
||||
/// In streaming mode, stdout is empty and cap state is also reported on the
|
||||
/// final stdout `process/outputDelta` notification.
|
||||
pub stdout_cap_reached: bool,
|
||||
/// Buffered stderr capture.
|
||||
///
|
||||
/// Empty when stderr was streamed via `process/outputDelta`.
|
||||
pub stderr: String,
|
||||
/// Whether stderr reached `outputBytesCap`.
|
||||
///
|
||||
/// In streaming mode, stderr is empty and cap state is also reported on the
|
||||
/// final stderr `process/outputDelta` notification.
|
||||
pub stderr_cap_reached: bool,
|
||||
}
|
||||
|
||||
// === Threads, Turns, and Items ===
|
||||
// Thread APIs
|
||||
#[derive(
|
||||
@@ -9147,6 +9345,97 @@ mod tests {
|
||||
assert_eq!(decoded, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_spawn_params_round_trips_without_sandbox_policy() {
|
||||
let params = ProcessSpawnParams {
|
||||
command: vec!["sleep".to_string(), "30".to_string()],
|
||||
process_handle: "sleep-1".to_string(),
|
||||
cwd: test_absolute_path(),
|
||||
tty: false,
|
||||
stream_stdin: false,
|
||||
stream_stdout_stderr: false,
|
||||
output_bytes_cap: None,
|
||||
timeout_ms: None,
|
||||
env: None,
|
||||
size: None,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(¶ms).expect("serialize process/spawn params");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"command": ["sleep", "30"],
|
||||
"processHandle": "sleep-1",
|
||||
"cwd": absolute_path_string("readable"),
|
||||
"env": null,
|
||||
"size": null,
|
||||
})
|
||||
);
|
||||
|
||||
let decoded =
|
||||
serde_json::from_value::<ProcessSpawnParams>(value).expect("deserialize round-trip");
|
||||
assert_eq!(decoded, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_spawn_params_distinguish_omitted_null_and_value_limits() {
|
||||
let base = json!({
|
||||
"command": ["sleep", "30"],
|
||||
"processHandle": "sleep-1",
|
||||
"cwd": absolute_path_string("readable"),
|
||||
});
|
||||
|
||||
let expected_omitted = ProcessSpawnParams {
|
||||
command: vec!["sleep".to_string(), "30".to_string()],
|
||||
process_handle: "sleep-1".to_string(),
|
||||
cwd: test_absolute_path(),
|
||||
tty: false,
|
||||
stream_stdin: false,
|
||||
stream_stdout_stderr: false,
|
||||
output_bytes_cap: None,
|
||||
timeout_ms: None,
|
||||
env: None,
|
||||
size: None,
|
||||
};
|
||||
let decoded =
|
||||
serde_json::from_value::<ProcessSpawnParams>(base).expect("deserialize omitted limits");
|
||||
assert_eq!(decoded, expected_omitted);
|
||||
|
||||
let decoded = serde_json::from_value::<ProcessSpawnParams>(json!({
|
||||
"command": ["sleep", "30"],
|
||||
"processHandle": "sleep-1",
|
||||
"cwd": absolute_path_string("readable"),
|
||||
"outputBytesCap": null,
|
||||
"timeoutMs": null,
|
||||
}))
|
||||
.expect("deserialize disabled limits");
|
||||
assert_eq!(
|
||||
decoded,
|
||||
ProcessSpawnParams {
|
||||
output_bytes_cap: Some(None),
|
||||
timeout_ms: Some(None),
|
||||
..expected_omitted.clone()
|
||||
}
|
||||
);
|
||||
|
||||
let decoded = serde_json::from_value::<ProcessSpawnParams>(json!({
|
||||
"command": ["sleep", "30"],
|
||||
"processHandle": "sleep-1",
|
||||
"cwd": absolute_path_string("readable"),
|
||||
"outputBytesCap": 123,
|
||||
"timeoutMs": 456,
|
||||
}))
|
||||
.expect("deserialize explicit limits");
|
||||
assert_eq!(
|
||||
decoded,
|
||||
ProcessSpawnParams {
|
||||
output_bytes_cap: Some(Some(123)),
|
||||
timeout_ms: Some(Some(456)),
|
||||
..expected_omitted
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_exec_params_round_trips_disable_output_cap() {
|
||||
let params = CommandExecParams {
|
||||
@@ -9379,6 +9668,110 @@ mod tests {
|
||||
assert_eq!(decoded, notification);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_control_params_round_trip() {
|
||||
let write = ProcessWriteStdinParams {
|
||||
process_handle: "proc-7".to_string(),
|
||||
delta_base64: None,
|
||||
close_stdin: true,
|
||||
};
|
||||
let value = serde_json::to_value(&write).expect("serialize process/writeStdin params");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"processHandle": "proc-7",
|
||||
"deltaBase64": null,
|
||||
"closeStdin": true,
|
||||
})
|
||||
);
|
||||
let decoded = serde_json::from_value::<ProcessWriteStdinParams>(value)
|
||||
.expect("deserialize process/writeStdin params");
|
||||
assert_eq!(decoded, write);
|
||||
|
||||
let resize = ProcessResizePtyParams {
|
||||
process_handle: "proc-7".to_string(),
|
||||
size: ProcessTerminalSize {
|
||||
rows: 50,
|
||||
cols: 160,
|
||||
},
|
||||
};
|
||||
let value = serde_json::to_value(&resize).expect("serialize process/resizePty params");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"processHandle": "proc-7",
|
||||
"size": {
|
||||
"rows": 50,
|
||||
"cols": 160,
|
||||
},
|
||||
})
|
||||
);
|
||||
let decoded = serde_json::from_value::<ProcessResizePtyParams>(value)
|
||||
.expect("deserialize process/resizePty params");
|
||||
assert_eq!(decoded, resize);
|
||||
|
||||
let kill = ProcessKillParams {
|
||||
process_handle: "proc-7".to_string(),
|
||||
};
|
||||
let value = serde_json::to_value(&kill).expect("serialize process/kill params");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"processHandle": "proc-7",
|
||||
})
|
||||
);
|
||||
let decoded =
|
||||
serde_json::from_value::<ProcessKillParams>(value).expect("deserialize process/kill");
|
||||
assert_eq!(decoded, kill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_notifications_round_trip() {
|
||||
let delta = ProcessOutputDeltaNotification {
|
||||
process_handle: "proc-1".to_string(),
|
||||
stream: ProcessOutputStream::Stdout,
|
||||
delta_base64: "AQI=".to_string(),
|
||||
cap_reached: false,
|
||||
};
|
||||
let value = serde_json::to_value(&delta).expect("serialize process/outputDelta");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"processHandle": "proc-1",
|
||||
"stream": "stdout",
|
||||
"deltaBase64": "AQI=",
|
||||
"capReached": false,
|
||||
})
|
||||
);
|
||||
let decoded = serde_json::from_value::<ProcessOutputDeltaNotification>(value)
|
||||
.expect("deserialize process/outputDelta");
|
||||
assert_eq!(decoded, delta);
|
||||
|
||||
let exited = ProcessExitedNotification {
|
||||
process_handle: "proc-1".to_string(),
|
||||
exit_code: 0,
|
||||
stdout: "out".to_string(),
|
||||
stdout_cap_reached: false,
|
||||
stderr: "err".to_string(),
|
||||
stderr_cap_reached: true,
|
||||
};
|
||||
let value = serde_json::to_value(&exited).expect("serialize process/exited");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"processHandle": "proc-1",
|
||||
"exitCode": 0,
|
||||
"stdout": "out",
|
||||
"stdoutCapReached": false,
|
||||
"stderr": "err",
|
||||
"stderrCapReached": true,
|
||||
})
|
||||
);
|
||||
let decoded = serde_json::from_value::<ProcessExitedNotification>(value)
|
||||
.expect("deserialize process/exited");
|
||||
assert_eq!(decoded, exited);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_execution_output_delta_round_trips() {
|
||||
let notification = CommandExecutionOutputDeltaNotification {
|
||||
|
||||
@@ -181,6 +181,12 @@ Example with notification opt-out:
|
||||
- `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.
|
||||
- `process/spawn` — experimental; spawn a standalone process without the Codex sandbox on the host where the app server is running; returns after the process starts and emits `process/outputDelta` and `process/exited` notifications.
|
||||
- `process/writeStdin` — experimental; write base64-decoded stdin bytes to a running `process/spawn` session or close stdin; returns `{}`.
|
||||
- `process/resizePty` — experimental; resize a running PTY-backed `process/spawn` session by `processHandle`; returns `{}`.
|
||||
- `process/kill` — experimental; terminate a running `process/spawn` session by `processHandle`; returns `{}`.
|
||||
- `process/outputDelta` — experimental; notification emitted for base64-encoded stdout/stderr chunks from a streaming `process/spawn` session.
|
||||
- `process/exited` — experimental; notification emitted when a `process/spawn` session exits.
|
||||
- `fs/readFile` — read an absolute file path and return `{ dataBase64 }`.
|
||||
- `fs/writeFile` — write an absolute file path from base64-encoded `{ dataBase64 }`; returns `{}`.
|
||||
- `fs/createDirectory` — create an absolute directory path; `recursive` defaults to `true`.
|
||||
@@ -930,6 +936,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin
|
||||
} }
|
||||
```
|
||||
|
||||
- Prefer using `process/spawn` when you want an explicitly unsandboxed process execution API with immediate spawn acknowledgement, handle-based control, output notifications, and an exit notification.
|
||||
- For clients that are already sandboxed externally, set the legacy `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`.
|
||||
|
||||
Notes:
|
||||
@@ -1001,6 +1008,83 @@ Streaming stdin/stdout uses base64 so PTY sessions can carry arbitrary bytes:
|
||||
- `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.
|
||||
|
||||
### Example: Process lifecycle execution
|
||||
|
||||
Use `process/spawn` to start a standalone argv-based process without the Codex sandbox on the host where the app server is running. The `process/*` API is experimental and requires `initialize.params.capabilities.experimentalApi: true`. The spawn response means the process has started and the `processHandle` is registered; completion is reported later through `process/exited`.
|
||||
|
||||
```json
|
||||
{ "method": "process/spawn", "id": 40, "params": {
|
||||
"command": ["cargo", "check"],
|
||||
"processHandle": "cargo-check-1",
|
||||
"cwd": "/Users/me/project", // required absolute path
|
||||
"env": { "RUST_LOG": null }, // optional; override or unset app-server env vars
|
||||
"outputBytesCap": 1048576, // optional; omit for default, null disables
|
||||
"timeoutMs": 10000 // optional; omit for default, null disables
|
||||
} }
|
||||
{ "id": 40, "result": {} }
|
||||
{ "method": "process/exited", "params": {
|
||||
"processHandle": "cargo-check-1",
|
||||
"exitCode": 0,
|
||||
"stdout": "...",
|
||||
"stdoutCapReached": false,
|
||||
"stderr": "",
|
||||
"stderrCapReached": false
|
||||
} }
|
||||
```
|
||||
|
||||
For interactive or streaming processes, set `tty: true` or `streamStdoutStderr: true` and route output notifications by `processHandle`:
|
||||
|
||||
```json
|
||||
{ "method": "process/spawn", "id": 41, "params": {
|
||||
"command": ["bash", "-i"],
|
||||
"processHandle": "bash-1",
|
||||
"cwd": "/Users/me/project",
|
||||
"tty": true,
|
||||
"size": { "rows": 40, "cols": 120 },
|
||||
"outputBytesCap": null,
|
||||
"timeoutMs": null
|
||||
} }
|
||||
{ "id": 41, "result": {} }
|
||||
{ "method": "process/outputDelta", "params": {
|
||||
"processHandle": "bash-1",
|
||||
"stream": "stdout",
|
||||
"deltaBase64": "YmFzaC00LjQkIA==",
|
||||
"capReached": false
|
||||
} }
|
||||
{ "method": "process/writeStdin", "id": 42, "params": {
|
||||
"processHandle": "bash-1",
|
||||
"deltaBase64": "cHdkCg=="
|
||||
} }
|
||||
{ "id": 42, "result": {} }
|
||||
{ "method": "process/resizePty", "id": 43, "params": {
|
||||
"processHandle": "bash-1",
|
||||
"size": { "rows": 48, "cols": 160 }
|
||||
} }
|
||||
{ "id": 43, "result": {} }
|
||||
{ "method": "process/kill", "id": 44, "params": {
|
||||
"processHandle": "bash-1"
|
||||
} }
|
||||
{ "id": 44, "result": {} }
|
||||
{ "method": "process/exited", "params": {
|
||||
"processHandle": "bash-1",
|
||||
"exitCode": 137,
|
||||
"stdout": "",
|
||||
"stdoutCapReached": false,
|
||||
"stderr": "",
|
||||
"stderrCapReached": false
|
||||
} }
|
||||
```
|
||||
|
||||
- Empty `command` arrays and empty `processHandle` strings are rejected.
|
||||
- `cwd` is required and must be absolute.
|
||||
- `process/spawn` is intentionally unsandboxed and does not define sandbox-selection fields such as `sandboxPolicy` or `permissionProfile`.
|
||||
- Duplicate active `processHandle` values are rejected on the same connection; the same handle can be reused after the prior process exits.
|
||||
- `tty: true` implies PTY mode plus `streamStdin: true` and `streamStdoutStderr: true`.
|
||||
- `process/writeStdin` accepts either `deltaBase64`, `closeStdin`, or both.
|
||||
- When omitted, `timeoutMs` and `outputBytesCap` fall back to server defaults. Set either field to `null` to disable that limit for terminal-style sessions.
|
||||
- `outputBytesCap` applies independently to `stdout` and `stderr`; `process/exited.stdoutCapReached` and `stderrCapReached` report whether each stream reached the cap. Streamed bytes are not duplicated into `process/exited`.
|
||||
- `process/outputDelta` and `process/exited` notifications are connection-scoped. If the originating connection closes, the server terminates the process.
|
||||
|
||||
### Example: Filesystem utilities
|
||||
|
||||
These methods operate on absolute paths on the host filesystem and cover reading, writing, directory traversal, copying, removal, and change notifications.
|
||||
|
||||
@@ -477,7 +477,7 @@ async fn run_command(params: RunCommandParams) {
|
||||
});
|
||||
let stderr_handle = spawn_process_output(SpawnProcessOutputParams {
|
||||
connection_id: request_id.connection_id,
|
||||
process_id,
|
||||
process_id: process_id.clone(),
|
||||
output_rx: stderr_rx,
|
||||
stdio_timeout_rx,
|
||||
outgoing: Arc::clone(&outgoing),
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::request_processors::InitializeRequestProcessor;
|
||||
use crate::request_processors::MarketplaceRequestProcessor;
|
||||
use crate::request_processors::McpRequestProcessor;
|
||||
use crate::request_processors::PluginRequestProcessor;
|
||||
use crate::request_processors::ProcessExecRequestProcessor;
|
||||
use crate::request_processors::SearchRequestProcessor;
|
||||
use crate::request_processors::ThreadGoalRequestProcessor;
|
||||
use crate::request_processors::ThreadRequestProcessor;
|
||||
@@ -157,6 +158,7 @@ pub(crate) struct MessageProcessor {
|
||||
apps_processor: AppsRequestProcessor,
|
||||
catalog_processor: CatalogRequestProcessor,
|
||||
command_exec_processor: CommandExecRequestProcessor,
|
||||
process_exec_processor: ProcessExecRequestProcessor,
|
||||
config_processor: ConfigRequestProcessor,
|
||||
device_key_processor: DeviceKeyRequestProcessor,
|
||||
external_agent_config_processor: ExternalAgentConfigRequestProcessor,
|
||||
@@ -335,6 +337,7 @@ impl MessageProcessor {
|
||||
Arc::clone(&config),
|
||||
outgoing.clone(),
|
||||
);
|
||||
let process_exec_processor = ProcessExecRequestProcessor::new(outgoing.clone());
|
||||
let feedback_processor = FeedbackRequestProcessor::new(
|
||||
auth_manager.clone(),
|
||||
Arc::clone(&thread_manager),
|
||||
@@ -457,6 +460,7 @@ impl MessageProcessor {
|
||||
apps_processor,
|
||||
catalog_processor,
|
||||
command_exec_processor,
|
||||
process_exec_processor,
|
||||
config_processor,
|
||||
device_key_processor,
|
||||
external_agent_config_processor,
|
||||
@@ -675,6 +679,9 @@ impl MessageProcessor {
|
||||
self.command_exec_processor
|
||||
.connection_closed(connection_id)
|
||||
.await;
|
||||
self.process_exec_processor
|
||||
.connection_closed(connection_id)
|
||||
.await;
|
||||
self.thread_processor.connection_closed(connection_id).await;
|
||||
}
|
||||
|
||||
@@ -1240,6 +1247,26 @@ impl MessageProcessor {
|
||||
.command_exec_terminate(request_id.clone(), params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::ProcessSpawn { params, .. } => self
|
||||
.process_exec_processor
|
||||
.process_spawn(request_id.clone(), params)
|
||||
.await
|
||||
.map(|()| None),
|
||||
ClientRequest::ProcessWriteStdin { params, .. } => {
|
||||
self.process_exec_processor
|
||||
.process_write_stdin(request_id.clone(), params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::ProcessKill { params, .. } => {
|
||||
self.process_exec_processor
|
||||
.process_kill(request_id.clone(), params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::ProcessResizePty { params, .. } => {
|
||||
self.process_exec_processor
|
||||
.process_resize_pty(request_id.clone(), params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::FeedbackUpload { params, .. } => {
|
||||
self.feedback_processor.feedback_upload(params).await
|
||||
}
|
||||
|
||||
@@ -429,6 +429,7 @@ mod initialize_processor;
|
||||
mod marketplace_processor;
|
||||
mod mcp_processor;
|
||||
mod plugins;
|
||||
mod process_exec_processor;
|
||||
mod search;
|
||||
mod thread_processor;
|
||||
mod token_usage_replay;
|
||||
@@ -449,6 +450,7 @@ pub(crate) use initialize_processor::InitializeRequestProcessor;
|
||||
pub(crate) use marketplace_processor::MarketplaceRequestProcessor;
|
||||
pub(crate) use mcp_processor::McpRequestProcessor;
|
||||
pub(crate) use plugins::PluginRequestProcessor;
|
||||
pub(crate) use process_exec_processor::ProcessExecRequestProcessor;
|
||||
pub(crate) use search::SearchRequestProcessor;
|
||||
pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor;
|
||||
pub(crate) use thread_processor::ThreadRequestProcessor;
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use codex_app_server_protocol::ClientResponsePayload;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::ProcessExitedNotification;
|
||||
use codex_app_server_protocol::ProcessKillParams;
|
||||
use codex_app_server_protocol::ProcessKillResponse;
|
||||
use codex_app_server_protocol::ProcessOutputDeltaNotification;
|
||||
use codex_app_server_protocol::ProcessOutputStream;
|
||||
use codex_app_server_protocol::ProcessResizePtyParams;
|
||||
use codex_app_server_protocol::ProcessResizePtyResponse;
|
||||
use codex_app_server_protocol::ProcessSpawnParams;
|
||||
use codex_app_server_protocol::ProcessSpawnResponse;
|
||||
use codex_app_server_protocol::ProcessTerminalSize;
|
||||
use codex_app_server_protocol::ProcessWriteStdinParams;
|
||||
use codex_app_server_protocol::ProcessWriteStdinResponse;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_core::exec::ExecExpiration;
|
||||
use codex_core::exec::ExecExpirationOutcome;
|
||||
use codex_core::exec::IO_DRAIN_TIMEOUT_MS;
|
||||
use codex_protocol::exec_output::bytes_to_string_smart;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
|
||||
use codex_utils_pty::ProcessHandle;
|
||||
use codex_utils_pty::SpawnedProcess;
|
||||
use codex_utils_pty::TerminalSize;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::watch;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::error_code::internal_error;
|
||||
use crate::error_code::invalid_params;
|
||||
use crate::error_code::invalid_request;
|
||||
use crate::outgoing_message::ConnectionId;
|
||||
use crate::outgoing_message::ConnectionRequestId;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
|
||||
const EXEC_TIMEOUT_EXIT_CODE: i32 = 124;
|
||||
const OUTPUT_CHUNK_SIZE_HINT: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ProcessExecRequestProcessor {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
process_exec_manager: ProcessExecManager,
|
||||
}
|
||||
|
||||
impl ProcessExecRequestProcessor {
|
||||
pub(crate) fn new(outgoing: Arc<OutgoingMessageSender>) -> Self {
|
||||
Self {
|
||||
outgoing,
|
||||
process_exec_manager: ProcessExecManager::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn process_spawn(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ProcessSpawnParams,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
let ProcessSpawnParams {
|
||||
command,
|
||||
process_handle,
|
||||
cwd,
|
||||
tty,
|
||||
stream_stdin,
|
||||
stream_stdout_stderr,
|
||||
output_bytes_cap,
|
||||
timeout_ms,
|
||||
env: env_overrides,
|
||||
size,
|
||||
} = params;
|
||||
let method_name = "process/spawn";
|
||||
tracing::debug!("{method_name} command: {command:?}");
|
||||
if command.is_empty() {
|
||||
return Err(invalid_request("command must not be empty"));
|
||||
}
|
||||
if process_handle.is_empty() {
|
||||
return Err(invalid_request("processHandle must not be empty"));
|
||||
}
|
||||
if size.is_some() && !tty {
|
||||
return Err(invalid_params("process/spawn size requires tty: true"));
|
||||
}
|
||||
let mut env = std::env::vars().collect::<HashMap<_, _>>();
|
||||
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 expiration = match timeout_ms {
|
||||
Some(Some(timeout_ms)) => match u64::try_from(timeout_ms) {
|
||||
Ok(timeout_ms) => timeout_ms.into(),
|
||||
Err(_) => {
|
||||
return Err(invalid_params(format!(
|
||||
"{method_name} timeoutMs must be non-negative, got {timeout_ms}"
|
||||
)));
|
||||
}
|
||||
},
|
||||
Some(None) => ExecExpiration::Cancellation(CancellationToken::new()),
|
||||
None => ExecExpiration::DefaultTimeout,
|
||||
};
|
||||
let output_bytes_cap = output_bytes_cap.unwrap_or(Some(DEFAULT_OUTPUT_BYTES_CAP));
|
||||
let size = size.map(terminal_size_from_protocol).transpose()?;
|
||||
|
||||
self.process_exec_manager
|
||||
.start(StartProcessParams {
|
||||
outgoing: self.outgoing.clone(),
|
||||
request_id,
|
||||
process_handle,
|
||||
command,
|
||||
cwd,
|
||||
env,
|
||||
expiration,
|
||||
tty,
|
||||
stream_stdin,
|
||||
stream_stdout_stderr,
|
||||
output_bytes_cap,
|
||||
size,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn process_write_stdin(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ProcessWriteStdinParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.process_exec_manager
|
||||
.write_stdin(request_id, params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn process_resize_pty(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ProcessResizePtyParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.process_exec_manager
|
||||
.resize_pty(request_id, params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn process_kill(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ProcessKillParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.process_exec_manager
|
||||
.kill(request_id, params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
|
||||
self.process_exec_manager
|
||||
.connection_closed(connection_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct ProcessExecManager {
|
||||
sessions: Arc<Mutex<HashMap<ConnectionProcessHandle, ProcessSession>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct ConnectionProcessHandle {
|
||||
connection_id: ConnectionId,
|
||||
process_handle: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProcessSession {
|
||||
control_tx: mpsc::Sender<ProcessControlRequest>,
|
||||
}
|
||||
|
||||
enum ProcessControl {
|
||||
Write { delta: Vec<u8>, close_stdin: bool },
|
||||
Resize { size: TerminalSize },
|
||||
Kill,
|
||||
}
|
||||
|
||||
struct ProcessControlRequest {
|
||||
control: ProcessControl,
|
||||
response_tx: Option<oneshot::Sender<Result<(), JSONRPCErrorError>>>,
|
||||
}
|
||||
|
||||
struct StartProcessParams {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
request_id: ConnectionRequestId,
|
||||
process_handle: String,
|
||||
command: Vec<String>,
|
||||
cwd: AbsolutePathBuf,
|
||||
env: HashMap<String, String>,
|
||||
expiration: ExecExpiration,
|
||||
tty: bool,
|
||||
stream_stdin: bool,
|
||||
stream_stdout_stderr: bool,
|
||||
output_bytes_cap: Option<usize>,
|
||||
size: Option<TerminalSize>,
|
||||
}
|
||||
|
||||
struct RunProcessParams {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
request_id: ConnectionRequestId,
|
||||
process_handle: String,
|
||||
spawned: SpawnedProcess,
|
||||
control_rx: mpsc::Receiver<ProcessControlRequest>,
|
||||
stream_stdin: bool,
|
||||
stream_stdout_stderr: bool,
|
||||
expiration: ExecExpiration,
|
||||
output_bytes_cap: Option<usize>,
|
||||
}
|
||||
|
||||
struct SpawnProcessOutputParams {
|
||||
connection_id: ConnectionId,
|
||||
process_handle: String,
|
||||
output_rx: mpsc::Receiver<Vec<u8>>,
|
||||
stdio_timeout_rx: watch::Receiver<bool>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
stream: ProcessOutputStream,
|
||||
stream_output: bool,
|
||||
output_bytes_cap: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ProcessOutputCapture {
|
||||
text: String,
|
||||
cap_reached: bool,
|
||||
}
|
||||
|
||||
impl ProcessExecManager {
|
||||
async fn start(&self, params: StartProcessParams) -> Result<(), JSONRPCErrorError> {
|
||||
let StartProcessParams {
|
||||
outgoing,
|
||||
request_id,
|
||||
process_handle,
|
||||
command,
|
||||
cwd,
|
||||
env,
|
||||
expiration,
|
||||
tty,
|
||||
stream_stdin,
|
||||
stream_stdout_stderr,
|
||||
output_bytes_cap,
|
||||
size,
|
||||
} = params;
|
||||
|
||||
let (program, args) = command
|
||||
.split_first()
|
||||
.ok_or_else(|| invalid_request("command must not be empty"))?;
|
||||
let stream_stdin = tty || stream_stdin;
|
||||
let stream_stdout_stderr = tty || stream_stdout_stderr;
|
||||
let arg0 = None;
|
||||
let (control_tx, control_rx) = mpsc::channel(32);
|
||||
let process_key = ConnectionProcessHandle {
|
||||
connection_id: request_id.connection_id,
|
||||
process_handle: process_handle.clone(),
|
||||
};
|
||||
|
||||
{
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
match sessions.entry(process_key.clone()) {
|
||||
Entry::Occupied(_) => {
|
||||
return Err(invalid_request(format!(
|
||||
"duplicate active process handle: {process_handle:?}",
|
||||
)));
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(ProcessSession { control_tx });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let spawned = if tty {
|
||||
codex_utils_pty::spawn_pty_process(
|
||||
program,
|
||||
args,
|
||||
cwd.as_path(),
|
||||
&env,
|
||||
&arg0,
|
||||
size.unwrap_or_default(),
|
||||
)
|
||||
.await
|
||||
} else if stream_stdin {
|
||||
codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), &env, &arg0).await
|
||||
} else {
|
||||
codex_utils_pty::spawn_pipe_process_no_stdin(program, args, cwd.as_path(), &env, &arg0)
|
||||
.await
|
||||
};
|
||||
let spawned = match spawned {
|
||||
Ok(spawned) => spawned,
|
||||
Err(err) => {
|
||||
self.sessions.lock().await.remove(&process_key);
|
||||
return Err(internal_error(format!("failed to spawn process: {err}")));
|
||||
}
|
||||
};
|
||||
|
||||
outgoing
|
||||
.send_response(request_id.clone(), ProcessSpawnResponse {})
|
||||
.await;
|
||||
|
||||
let sessions = Arc::clone(&self.sessions);
|
||||
tokio::spawn(async move {
|
||||
run_process(RunProcessParams {
|
||||
outgoing,
|
||||
request_id,
|
||||
process_handle,
|
||||
spawned,
|
||||
control_rx,
|
||||
stream_stdin,
|
||||
stream_stdout_stderr,
|
||||
expiration,
|
||||
output_bytes_cap,
|
||||
})
|
||||
.await;
|
||||
sessions.lock().await.remove(&process_key);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_stdin(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ProcessWriteStdinParams,
|
||||
) -> Result<ProcessWriteStdinResponse, JSONRPCErrorError> {
|
||||
if params.delta_base64.is_none() && !params.close_stdin {
|
||||
return Err(invalid_params(
|
||||
"process/writeStdin requires deltaBase64 or closeStdin",
|
||||
));
|
||||
}
|
||||
|
||||
let delta = match params.delta_base64 {
|
||||
Some(delta_base64) => STANDARD
|
||||
.decode(delta_base64)
|
||||
.map_err(|err| invalid_params(format!("invalid deltaBase64: {err}")))?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
self.send_control(
|
||||
request_id.connection_id,
|
||||
params.process_handle,
|
||||
ProcessControl::Write {
|
||||
delta,
|
||||
close_stdin: params.close_stdin,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ProcessWriteStdinResponse {})
|
||||
}
|
||||
|
||||
async fn kill(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ProcessKillParams,
|
||||
) -> Result<ProcessKillResponse, JSONRPCErrorError> {
|
||||
self.send_control(
|
||||
request_id.connection_id,
|
||||
params.process_handle,
|
||||
ProcessControl::Kill,
|
||||
)
|
||||
.await?;
|
||||
Ok(ProcessKillResponse {})
|
||||
}
|
||||
|
||||
async fn resize_pty(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ProcessResizePtyParams,
|
||||
) -> Result<ProcessResizePtyResponse, JSONRPCErrorError> {
|
||||
self.send_control(
|
||||
request_id.connection_id,
|
||||
params.process_handle,
|
||||
ProcessControl::Resize {
|
||||
size: terminal_size_from_protocol(params.size)?,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(ProcessResizePtyResponse {})
|
||||
}
|
||||
|
||||
async fn connection_closed(&self, connection_id: ConnectionId) {
|
||||
let controls = {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let process_handles = sessions
|
||||
.keys()
|
||||
.filter(|process_handle| process_handle.connection_id == connection_id)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut controls = Vec::with_capacity(process_handles.len());
|
||||
for process_handle in process_handles {
|
||||
if let Some(control) = sessions.remove(&process_handle) {
|
||||
controls.push(control);
|
||||
}
|
||||
}
|
||||
controls
|
||||
};
|
||||
|
||||
for control in controls {
|
||||
let _ = control
|
||||
.control_tx
|
||||
.send(ProcessControlRequest {
|
||||
control: ProcessControl::Kill,
|
||||
response_tx: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_control(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
process_handle: String,
|
||||
control: ProcessControl,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
let process_key = ConnectionProcessHandle {
|
||||
connection_id,
|
||||
process_handle,
|
||||
};
|
||||
let session = self
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.get(&process_key)
|
||||
.cloned()
|
||||
.ok_or_else(|| no_active_process_error(&process_key.process_handle))?;
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
session
|
||||
.control_tx
|
||||
.send(ProcessControlRequest {
|
||||
control,
|
||||
response_tx: Some(response_tx),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| process_no_longer_running_error(&process_key.process_handle))?;
|
||||
response_rx
|
||||
.await
|
||||
.map_err(|_| process_no_longer_running_error(&process_key.process_handle))?
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_process(params: RunProcessParams) {
|
||||
let RunProcessParams {
|
||||
outgoing,
|
||||
request_id,
|
||||
process_handle,
|
||||
spawned,
|
||||
control_rx,
|
||||
stream_stdin,
|
||||
stream_stdout_stderr,
|
||||
expiration,
|
||||
output_bytes_cap,
|
||||
} = params;
|
||||
let mut control_rx = control_rx;
|
||||
let mut control_open = true;
|
||||
let expiration = expiration.wait_with_outcome();
|
||||
tokio::pin!(expiration);
|
||||
let SpawnedProcess {
|
||||
session,
|
||||
stdout_rx,
|
||||
stderr_rx,
|
||||
exit_rx,
|
||||
} = spawned;
|
||||
tokio::pin!(exit_rx);
|
||||
let mut expiration_outcome = None;
|
||||
let (stdio_timeout_tx, stdio_timeout_rx) = watch::channel(false);
|
||||
|
||||
let stdout_handle = collect_spawn_process_output(SpawnProcessOutputParams {
|
||||
connection_id: request_id.connection_id,
|
||||
process_handle: process_handle.clone(),
|
||||
output_rx: stdout_rx,
|
||||
stdio_timeout_rx: stdio_timeout_rx.clone(),
|
||||
outgoing: Arc::clone(&outgoing),
|
||||
stream: ProcessOutputStream::Stdout,
|
||||
stream_output: stream_stdout_stderr,
|
||||
output_bytes_cap,
|
||||
});
|
||||
let stderr_handle = collect_spawn_process_output(SpawnProcessOutputParams {
|
||||
connection_id: request_id.connection_id,
|
||||
process_handle: process_handle.clone(),
|
||||
output_rx: stderr_rx,
|
||||
stdio_timeout_rx,
|
||||
outgoing: Arc::clone(&outgoing),
|
||||
stream: ProcessOutputStream::Stderr,
|
||||
stream_output: stream_stdout_stderr,
|
||||
output_bytes_cap,
|
||||
});
|
||||
|
||||
let exit_code = loop {
|
||||
tokio::select! {
|
||||
control = control_rx.recv(), if control_open => {
|
||||
match control {
|
||||
Some(ProcessControlRequest { control, response_tx }) => {
|
||||
let result = match control {
|
||||
ProcessControl::Write { delta, close_stdin } => {
|
||||
handle_process_write(
|
||||
&session,
|
||||
stream_stdin,
|
||||
delta,
|
||||
close_stdin,
|
||||
).await
|
||||
}
|
||||
ProcessControl::Resize { size } => {
|
||||
handle_process_resize(&session, size)
|
||||
}
|
||||
ProcessControl::Kill => {
|
||||
session.request_terminate();
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
if let Some(response_tx) = response_tx
|
||||
&& response_tx.send(result).is_err()
|
||||
{
|
||||
tracing::debug!(
|
||||
process_handle = %process_handle,
|
||||
"process control response receiver dropped"
|
||||
);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
control_open = false;
|
||||
session.request_terminate();
|
||||
}
|
||||
}
|
||||
}
|
||||
outcome = &mut expiration, if expiration_outcome.is_none() => {
|
||||
expiration_outcome = Some(outcome);
|
||||
session.request_terminate();
|
||||
}
|
||||
exit = &mut exit_rx => {
|
||||
if matches!(expiration_outcome, Some(ExecExpirationOutcome::TimedOut)) {
|
||||
break EXEC_TIMEOUT_EXIT_CODE;
|
||||
} else {
|
||||
break exit.unwrap_or(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Give stdout/stderr readers a bounded grace period to drain after process exit.
|
||||
let timeout_handle = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(IO_DRAIN_TIMEOUT_MS)).await;
|
||||
let _ = stdio_timeout_tx.send(true);
|
||||
});
|
||||
|
||||
let stdout = stdout_handle.await.unwrap_or_default();
|
||||
let stderr = stderr_handle.await.unwrap_or_default();
|
||||
timeout_handle.abort();
|
||||
|
||||
outgoing
|
||||
.send_server_notification_to_connection_and_wait(
|
||||
request_id.connection_id,
|
||||
ServerNotification::ProcessExited(ProcessExitedNotification {
|
||||
process_handle,
|
||||
exit_code,
|
||||
stdout: stdout.text,
|
||||
stdout_cap_reached: stdout.cap_reached,
|
||||
stderr: stderr.text,
|
||||
stderr_cap_reached: stderr.cap_reached,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn collect_spawn_process_output(
|
||||
params: SpawnProcessOutputParams,
|
||||
) -> tokio::task::JoinHandle<ProcessOutputCapture> {
|
||||
let SpawnProcessOutputParams {
|
||||
connection_id,
|
||||
process_handle,
|
||||
mut output_rx,
|
||||
mut stdio_timeout_rx,
|
||||
outgoing,
|
||||
stream,
|
||||
stream_output,
|
||||
output_bytes_cap,
|
||||
} = params;
|
||||
tokio::spawn(async move {
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut observed_num_bytes = 0usize;
|
||||
let mut cap_reached = false;
|
||||
loop {
|
||||
let mut chunk = tokio::select! {
|
||||
chunk = output_rx.recv() => match chunk {
|
||||
Some(chunk) => chunk,
|
||||
None => break,
|
||||
},
|
||||
_ = stdio_timeout_rx.wait_for(|&v| v) => break,
|
||||
};
|
||||
while chunk.len() < OUTPUT_CHUNK_SIZE_HINT
|
||||
&& let Ok(next_chunk) = output_rx.try_recv()
|
||||
{
|
||||
chunk.extend_from_slice(&next_chunk);
|
||||
}
|
||||
let capped_chunk = match output_bytes_cap {
|
||||
Some(output_bytes_cap) => {
|
||||
let capped_chunk_len = output_bytes_cap
|
||||
.saturating_sub(observed_num_bytes)
|
||||
.min(chunk.len());
|
||||
observed_num_bytes += capped_chunk_len;
|
||||
&chunk[0..capped_chunk_len]
|
||||
}
|
||||
None => chunk.as_slice(),
|
||||
};
|
||||
cap_reached = Some(observed_num_bytes) == output_bytes_cap;
|
||||
if stream_output {
|
||||
outgoing
|
||||
.send_server_notification_to_connection_and_wait(
|
||||
connection_id,
|
||||
ServerNotification::ProcessOutputDelta(ProcessOutputDeltaNotification {
|
||||
process_handle: process_handle.clone(),
|
||||
stream,
|
||||
delta_base64: STANDARD.encode(capped_chunk),
|
||||
cap_reached,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
buffer.extend_from_slice(capped_chunk);
|
||||
}
|
||||
if cap_reached {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ProcessOutputCapture {
|
||||
text: bytes_to_string_smart(&buffer),
|
||||
cap_reached,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_process_write(
|
||||
session: &ProcessHandle,
|
||||
stream_stdin: bool,
|
||||
delta: Vec<u8>,
|
||||
close_stdin: bool,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
if !stream_stdin {
|
||||
return Err(invalid_request(
|
||||
"stdin streaming is not enabled for this process",
|
||||
));
|
||||
}
|
||||
if !delta.is_empty() {
|
||||
session
|
||||
.writer_sender()
|
||||
.send(delta)
|
||||
.await
|
||||
.map_err(|_| invalid_request("stdin is already closed"))?;
|
||||
}
|
||||
if close_stdin {
|
||||
// Closing drops our sender; the writer task still drains any bytes
|
||||
// accepted above before its receiver observes EOF and closes stdin.
|
||||
session.close_stdin();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_process_resize(
|
||||
session: &ProcessHandle,
|
||||
size: TerminalSize,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
session
|
||||
.resize(size)
|
||||
.map_err(|err| invalid_request(format!("failed to resize PTY: {err}")))
|
||||
}
|
||||
|
||||
fn terminal_size_from_protocol(
|
||||
size: ProcessTerminalSize,
|
||||
) -> Result<TerminalSize, JSONRPCErrorError> {
|
||||
if size.rows == 0 || size.cols == 0 {
|
||||
return Err(invalid_params(
|
||||
"process size rows and cols must be greater than 0",
|
||||
));
|
||||
}
|
||||
Ok(TerminalSize {
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
})
|
||||
}
|
||||
|
||||
fn no_active_process_error(process_handle: &str) -> JSONRPCErrorError {
|
||||
invalid_request(format!(
|
||||
"no active process for process handle {process_handle:?}"
|
||||
))
|
||||
}
|
||||
|
||||
fn process_no_longer_running_error(process_handle: &str) -> JSONRPCErrorError {
|
||||
invalid_request(format!("process {process_handle:?} is no longer running"))
|
||||
}
|
||||
@@ -27,6 +27,10 @@ pub(crate) enum RequestSerializationQueueKey {
|
||||
connection_id: ConnectionId,
|
||||
process_id: String,
|
||||
},
|
||||
Process {
|
||||
connection_id: ConnectionId,
|
||||
process_handle: String,
|
||||
},
|
||||
FuzzyFileSearchSession {
|
||||
session_id: String,
|
||||
},
|
||||
@@ -54,6 +58,10 @@ impl RequestSerializationQueueKey {
|
||||
process_id,
|
||||
}
|
||||
}
|
||||
ClientRequestSerializationScope::Process { process_handle } => Self::Process {
|
||||
connection_id,
|
||||
process_handle,
|
||||
},
|
||||
ClientRequestSerializationScope::FuzzyFileSearchSession { session_id } => {
|
||||
Self::FuzzyFileSearchSession { session_id }
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginSkillReadParams;
|
||||
use codex_app_server_protocol::PluginUninstallParams;
|
||||
use codex_app_server_protocol::ProcessKillParams;
|
||||
use codex_app_server_protocol::ProcessResizePtyParams;
|
||||
use codex_app_server_protocol::ProcessSpawnParams;
|
||||
use codex_app_server_protocol::ProcessWriteStdinParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
|
||||
@@ -741,6 +745,42 @@ impl McpProcess {
|
||||
self.send_request("command/exec", params).await
|
||||
}
|
||||
|
||||
/// Send a `process/spawn` JSON-RPC request (v2).
|
||||
pub async fn send_process_spawn_request(
|
||||
&mut self,
|
||||
params: ProcessSpawnParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("process/spawn", params).await
|
||||
}
|
||||
|
||||
/// Send a `process/writeStdin` JSON-RPC request (v2).
|
||||
pub async fn send_process_write_stdin_request(
|
||||
&mut self,
|
||||
params: ProcessWriteStdinParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("process/writeStdin", params).await
|
||||
}
|
||||
|
||||
/// Send a `process/resizePty` JSON-RPC request (v2).
|
||||
pub async fn send_process_resize_pty_request(
|
||||
&mut self,
|
||||
params: ProcessResizePtyParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("process/resizePty", params).await
|
||||
}
|
||||
|
||||
/// Send a `process/kill` JSON-RPC request (v2).
|
||||
pub async fn send_process_kill_request(
|
||||
&mut self,
|
||||
params: ProcessKillParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("process/kill", params).await
|
||||
}
|
||||
|
||||
/// Send a `command/exec/write` JSON-RPC request (v2).
|
||||
pub async fn send_command_exec_write_request(
|
||||
&mut self,
|
||||
|
||||
@@ -35,6 +35,7 @@ mod plugin_list;
|
||||
mod plugin_read;
|
||||
mod plugin_share;
|
||||
mod plugin_uninstall;
|
||||
mod process_exec;
|
||||
mod rate_limits;
|
||||
mod realtime_conversation;
|
||||
#[cfg(debug_assertions)]
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_mock_responses_server_sequence_unchecked;
|
||||
use codex_app_server_protocol::ProcessExitedNotification;
|
||||
use codex_app_server_protocol::ProcessKillParams;
|
||||
use codex_app_server_protocol::ProcessSpawnParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::MockServer;
|
||||
|
||||
use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT;
|
||||
use super::connection_handling_websocket::create_config_toml;
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_spawn_returns_before_exit_and_emits_exit_notification() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?;
|
||||
|
||||
let process_handle = "one-shot-1".to_string();
|
||||
let probe_file = codex_home.path().join("process-created");
|
||||
let release_file = codex_home.path().join("process-release");
|
||||
// Use a probe/release handshake instead of asserting on wall-clock timing:
|
||||
// the child proves it started by writing the probe file, then waits for the
|
||||
// test to create the release file before it can emit output and exit.
|
||||
let command = if cfg!(windows) {
|
||||
vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-NonInteractive".to_string(),
|
||||
"-Command".to_string(),
|
||||
concat!(
|
||||
"[IO.File]::WriteAllText($env:CODEX_PROCESS_EXEC_PROBE_FILE, 'process'); ",
|
||||
"while (!(Test-Path -LiteralPath $env:CODEX_PROCESS_EXEC_RELEASE_FILE)) { ",
|
||||
"Start-Sleep -Milliseconds 20 ",
|
||||
"}; ",
|
||||
"[Console]::Out.Write('process-out'); ",
|
||||
"[Console]::Error.Write('process-err')",
|
||||
)
|
||||
.to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
concat!(
|
||||
"printf process > \"$CODEX_PROCESS_EXEC_PROBE_FILE\"; ",
|
||||
"while [ ! -e \"$CODEX_PROCESS_EXEC_RELEASE_FILE\" ]; do sleep 0.05; done; ",
|
||||
"printf process-out; ",
|
||||
"printf process-err >&2",
|
||||
)
|
||||
.to_string(),
|
||||
]
|
||||
};
|
||||
let env = HashMap::from([
|
||||
(
|
||||
"CODEX_PROCESS_EXEC_PROBE_FILE".to_string(),
|
||||
Some(probe_file.display().to_string()),
|
||||
),
|
||||
(
|
||||
"CODEX_PROCESS_EXEC_RELEASE_FILE".to_string(),
|
||||
Some(release_file.display().to_string()),
|
||||
),
|
||||
]);
|
||||
let spawn_request_id = mcp
|
||||
.send_process_spawn_request(ProcessSpawnParams {
|
||||
env: Some(env),
|
||||
output_bytes_cap: Some(None),
|
||||
timeout_ms: Some(None),
|
||||
..process_spawn_params(process_handle.clone(), codex_home.path(), command)?
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response = mcp
|
||||
.read_stream_until_response_message(RequestId::Integer(spawn_request_id))
|
||||
.await?;
|
||||
assert_eq!(response.result, serde_json::json!({}));
|
||||
|
||||
wait_for_file(&probe_file).await?;
|
||||
assert_eq!(std::fs::read_to_string(&probe_file)?, "process");
|
||||
std::fs::write(&release_file, "release")?;
|
||||
|
||||
let exited = read_process_exited(&mut mcp).await?;
|
||||
assert_eq!(
|
||||
exited,
|
||||
ProcessExitedNotification {
|
||||
process_handle,
|
||||
exit_code: 0,
|
||||
stdout: "process-out".to_string(),
|
||||
stdout_cap_reached: false,
|
||||
stderr: "process-err".to_string(),
|
||||
stderr_cap_reached: false,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_spawn_reports_buffered_output_cap_reached() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?;
|
||||
|
||||
let process_handle = "capped-one-shot-1".to_string();
|
||||
let command = if cfg!(windows) {
|
||||
vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-NonInteractive".to_string(),
|
||||
"-Command".to_string(),
|
||||
"[Console]::Out.Write('abcde'); [Console]::Error.Write('12345')".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"printf abcde; printf 12345 >&2".to_string(),
|
||||
]
|
||||
};
|
||||
let spawn_request_id = mcp
|
||||
.send_process_spawn_request(ProcessSpawnParams {
|
||||
output_bytes_cap: Some(Some(3)),
|
||||
..process_spawn_params(process_handle.clone(), codex_home.path(), command)?
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response = mcp
|
||||
.read_stream_until_response_message(RequestId::Integer(spawn_request_id))
|
||||
.await?;
|
||||
assert_eq!(response.result, serde_json::json!({}));
|
||||
|
||||
let exited = read_process_exited(&mut mcp).await?;
|
||||
assert_eq!(
|
||||
exited,
|
||||
ProcessExitedNotification {
|
||||
process_handle,
|
||||
exit_code: 0,
|
||||
stdout: "abc".to_string(),
|
||||
stdout_cap_reached: true,
|
||||
stderr: "123".to_string(),
|
||||
stderr_cap_reached: true,
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_kill_terminates_running_process() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?;
|
||||
|
||||
let process_handle = "sleep-process-1".to_string();
|
||||
let command = if cfg!(windows) {
|
||||
vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-NonInteractive".to_string(),
|
||||
"-Command".to_string(),
|
||||
"Start-Sleep -Seconds 30".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()]
|
||||
};
|
||||
let spawn_request_id = mcp
|
||||
.send_process_spawn_request(process_spawn_params(
|
||||
process_handle.clone(),
|
||||
codex_home.path(),
|
||||
command,
|
||||
)?)
|
||||
.await?;
|
||||
|
||||
let response = mcp
|
||||
.read_stream_until_response_message(RequestId::Integer(spawn_request_id))
|
||||
.await?;
|
||||
assert_eq!(response.result, serde_json::json!({}));
|
||||
|
||||
let kill_request_id = mcp
|
||||
.send_process_kill_request(ProcessKillParams {
|
||||
process_handle: process_handle.clone(),
|
||||
})
|
||||
.await?;
|
||||
let kill_response = mcp
|
||||
.read_stream_until_response_message(RequestId::Integer(kill_request_id))
|
||||
.await?;
|
||||
assert_eq!(kill_response.result, serde_json::json!({}));
|
||||
|
||||
let exited = read_process_exited(&mut mcp).await?;
|
||||
assert_eq!(exited.process_handle, process_handle);
|
||||
assert_ne!(exited.exit_code, 0);
|
||||
assert_eq!(exited.stdout, "");
|
||||
assert!(!exited.stdout_cap_reached);
|
||||
assert_eq!(exited.stderr, "");
|
||||
assert!(!exited.stderr_cap_reached);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn initialized_mcp(codex_home: &Path) -> Result<(MockServer, McpProcess)> {
|
||||
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
||||
create_config_toml(codex_home, &server.uri(), "never")?;
|
||||
let mut mcp = McpProcess::new(codex_home).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
Ok((server, mcp))
|
||||
}
|
||||
|
||||
fn process_spawn_params(
|
||||
process_handle: String,
|
||||
cwd: &Path,
|
||||
command: Vec<String>,
|
||||
) -> Result<ProcessSpawnParams> {
|
||||
Ok(ProcessSpawnParams {
|
||||
command,
|
||||
process_handle,
|
||||
cwd: AbsolutePathBuf::try_from(cwd)?,
|
||||
tty: false,
|
||||
stream_stdin: false,
|
||||
stream_stdout_stderr: false,
|
||||
output_bytes_cap: None,
|
||||
timeout_ms: None,
|
||||
env: None,
|
||||
size: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_process_exited(mcp: &mut McpProcess) -> Result<ProcessExitedNotification> {
|
||||
let notification = mcp
|
||||
.read_stream_until_notification_message("process/exited")
|
||||
.await?;
|
||||
let params = notification
|
||||
.params
|
||||
.context("process/exited notification should include params")?;
|
||||
serde_json::from_value(params).context("deserialize process/exited notification")
|
||||
}
|
||||
|
||||
async fn wait_for_file(path: &Path) -> Result<()> {
|
||||
timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
while !path.exists() {
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("timed out waiting for process probe file")
|
||||
}
|
||||
@@ -153,6 +153,8 @@ pub(super) fn server_notification_thread_target(
|
||||
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
|
||||
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
|
||||
| ServerNotification::CommandExecOutputDelta(_)
|
||||
| ServerNotification::ProcessOutputDelta(_)
|
||||
| ServerNotification::ProcessExited(_)
|
||||
| ServerNotification::FsChanged(_)
|
||||
| ServerNotification::WindowsWorldWritableWarning(_)
|
||||
| ServerNotification::WindowsSandboxSetupCompleted(_)
|
||||
|
||||
@@ -6390,6 +6390,8 @@ impl ChatWidget {
|
||||
| ServerNotification::ThreadUnarchived(_)
|
||||
| ServerNotification::RawResponseItemCompleted(_)
|
||||
| ServerNotification::CommandExecOutputDelta(_)
|
||||
| ServerNotification::ProcessOutputDelta(_)
|
||||
| ServerNotification::ProcessExited(_)
|
||||
| ServerNotification::FileChangePatchUpdated(_)
|
||||
| ServerNotification::McpToolCallProgress(_)
|
||||
| ServerNotification::McpServerOauthLoginCompleted(_)
|
||||
|
||||
Reference in New Issue
Block a user