codex-app-server-sdk 0.3.1__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/PKG-INFO +1 -1
- codex_app_server_sdk-0.3.2/docs/approvals-and-sandbox.md +117 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/index.md +26 -2
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/recipes/index.md +1 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/mkdocs.yml +1 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/pyproject.toml +1 -1
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/__init__.py +16 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/client.py +303 -1
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/models.py +112 -4
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/protocol.py +14 -0
- codex_app_server_sdk-0.3.2/tests/test_approval_requests.py +183 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/uv.lock +1 -1
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/.github/workflows/docs.yml +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/.github/workflows/publish.yml +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/.gitignore +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/LICENSE +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/README.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/RELEASE.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/client.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/errors.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/index.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/models.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/package.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/protocol.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/transport.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/behavior-guarantees.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/conversation.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/examples.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/getting-started.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/protocol-mapping.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/threads-and-config.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/timeouts-continuation-cancel.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/transports.md +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/chat_session_stdio.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/chat_session_websocket.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/chat_steps_rich.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/thread_concurrent_handles.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/thread_config_and_fork.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/thread_ops_showcase.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/thread_resume_by_id.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/scripts/check_chat_method_links.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/errors.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/py.typed +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/transport.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_chat_assembly_completed_item.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_chat_continuation.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_chat_steps.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_client_chat_once.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_client_lifecycle.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_protocol.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_thread_handle.py +0 -0
- {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_transport.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-app-server-sdk
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Async Python client for Codex app-server over stdio and websocket.
|
|
5
5
|
Project-URL: Documentation, https://emsi.github.io/codex-app-server-sdk/
|
|
6
6
|
Project-URL: Repository, https://github.com/emsi/codex-app-server-sdk
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Approval requests and sandbox policies
|
|
2
|
+
|
|
3
|
+
## Related API
|
|
4
|
+
|
|
5
|
+
- [`ThreadConfig`](api/models.md#codex_app_server_sdk.models.ThreadConfig)
|
|
6
|
+
- [`TurnOverrides`](api/models.md#codex_app_server_sdk.models.TurnOverrides)
|
|
7
|
+
- [`SandboxMode`](api/models.md#codex_app_server_sdk.models.SandboxMode)
|
|
8
|
+
- [`SandboxPolicy`](api/models.md#codex_app_server_sdk.models.SandboxPolicy)
|
|
9
|
+
- [`ApprovalPolicy`](api/models.md#codex_app_server_sdk.models.ApprovalPolicy)
|
|
10
|
+
- [`CodexClient.set_approval_handler(...)`](api/client.md#codex_app_server_sdk.client.CodexClient.set_approval_handler)
|
|
11
|
+
- [`CodexClient.approval_requests(...)`](api/client.md#codex_app_server_sdk.client.CodexClient.approval_requests)
|
|
12
|
+
- [`CodexClient.respond_approval(...)`](api/client.md#codex_app_server_sdk.client.CodexClient.respond_approval)
|
|
13
|
+
- [`CodexClient.approve_approval(...)`](api/client.md#codex_app_server_sdk.client.CodexClient.approve_approval)
|
|
14
|
+
- [`CodexClient.decline_approval(...)`](api/client.md#codex_app_server_sdk.client.CodexClient.decline_approval)
|
|
15
|
+
- [`CodexClient.cancel_approval(...)`](api/client.md#codex_app_server_sdk.client.CodexClient.cancel_approval)
|
|
16
|
+
|
|
17
|
+
## Thread-level policy and sandbox mode
|
|
18
|
+
|
|
19
|
+
`ThreadConfig` controls defaults persisted for subsequent turns on that thread.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from codex_app_server_sdk import ThreadConfig
|
|
23
|
+
|
|
24
|
+
cfg = ThreadConfig(
|
|
25
|
+
approval_policy="on-request",
|
|
26
|
+
sandbox="workspace-write",
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`approval_policy` values:
|
|
31
|
+
|
|
32
|
+
- `untrusted`
|
|
33
|
+
- `on-failure` (deprecated by protocol, still accepted)
|
|
34
|
+
- `on-request`
|
|
35
|
+
- `never`
|
|
36
|
+
|
|
37
|
+
`sandbox` values:
|
|
38
|
+
|
|
39
|
+
- `read-only`
|
|
40
|
+
- `workspace-write`
|
|
41
|
+
- `danger-full-access`
|
|
42
|
+
|
|
43
|
+
## Turn-level sandbox policy
|
|
44
|
+
|
|
45
|
+
Use `TurnOverrides.sandbox_policy` for per-turn policy payloads.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from codex_app_server_sdk import TurnOverrides
|
|
49
|
+
|
|
50
|
+
turn = TurnOverrides(
|
|
51
|
+
sandbox_policy={
|
|
52
|
+
"type": "workspaceWrite",
|
|
53
|
+
"networkAccess": False,
|
|
54
|
+
"writableRoots": ["/tmp"],
|
|
55
|
+
},
|
|
56
|
+
approval_policy="on-request",
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`TurnOverrides.sandbox_policy` accepts either:
|
|
61
|
+
|
|
62
|
+
- typed `SandboxPolicy` structures, or
|
|
63
|
+
- raw mapping payloads (for compatibility with server extensions).
|
|
64
|
+
|
|
65
|
+
## Approval request handling
|
|
66
|
+
|
|
67
|
+
The client handles v2 server-initiated approval requests:
|
|
68
|
+
|
|
69
|
+
- `item/commandExecution/requestApproval`
|
|
70
|
+
- `item/fileChange/requestApproval`
|
|
71
|
+
|
|
72
|
+
If no handler is registered, the SDK auto-responds with `decline` (continue turn).
|
|
73
|
+
|
|
74
|
+
### Callback mode
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from codex_app_server_sdk import (
|
|
78
|
+
CodexClient,
|
|
79
|
+
CommandApprovalRequest,
|
|
80
|
+
CommandApprovalWithExecpolicyAmendment,
|
|
81
|
+
FileChangeApprovalRequest,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def handler(req):
|
|
86
|
+
if isinstance(req, CommandApprovalRequest):
|
|
87
|
+
if req.reason and "network" in req.reason.lower():
|
|
88
|
+
return "decline"
|
|
89
|
+
return CommandApprovalWithExecpolicyAmendment(["uv", "run"])
|
|
90
|
+
|
|
91
|
+
if isinstance(req, FileChangeApprovalRequest):
|
|
92
|
+
return "accept_for_session"
|
|
93
|
+
|
|
94
|
+
return "decline"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async with CodexClient.connect_stdio() as client:
|
|
98
|
+
client.set_approval_handler(handler)
|
|
99
|
+
result = await client.chat_once("Run a command that may request approval.")
|
|
100
|
+
print(result.final_text)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Stream mode (manual response)
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from codex_app_server_sdk import CodexClient, CommandApprovalRequest
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async with CodexClient.connect_stdio() as client:
|
|
110
|
+
async for req in client.approval_requests():
|
|
111
|
+
if isinstance(req, CommandApprovalRequest):
|
|
112
|
+
await client.approve_approval(req, for_session=True)
|
|
113
|
+
else:
|
|
114
|
+
await client.decline_approval(req)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`approval_requests()` is observational. If a callback is configured, callback handling remains authoritative.
|
|
@@ -20,16 +20,40 @@ This documentation is organized around:
|
|
|
20
20
|
|
|
21
21
|
## Install
|
|
22
22
|
|
|
23
|
+
Install `uv` (if needed):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Install the package from PyPI:
|
|
30
|
+
|
|
23
31
|
```bash
|
|
24
|
-
uv
|
|
32
|
+
uv add codex-app-server-sdk
|
|
25
33
|
```
|
|
26
34
|
|
|
27
|
-
|
|
35
|
+
Or pip-compatible install in the active environment:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv pip install codex-app-server-sdk
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Contributor docs workflow
|
|
42
|
+
|
|
43
|
+
Install development dependencies:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv sync --group dev
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Serve docs locally:
|
|
28
50
|
|
|
29
51
|
```bash
|
|
30
52
|
uv run zensical serve
|
|
31
53
|
```
|
|
32
54
|
|
|
55
|
+
Build docs:
|
|
56
|
+
|
|
33
57
|
```bash
|
|
34
58
|
uv run zensical build
|
|
35
59
|
```
|
|
@@ -14,6 +14,7 @@ Task-oriented guides for common usage patterns.
|
|
|
14
14
|
|
|
15
15
|
- [Conversation APIs](../conversation.md)
|
|
16
16
|
- [Transports](../transports.md)
|
|
17
|
+
- [Approval requests and sandbox policies](../approvals-and-sandbox.md)
|
|
17
18
|
- [Timeouts, continuation, cancel](../timeouts-continuation-cancel.md)
|
|
18
19
|
- [Threads and configuration](../threads-and-config.md)
|
|
19
20
|
- [Examples](../examples.md)
|
|
@@ -42,6 +42,7 @@ nav:
|
|
|
42
42
|
- Overview: recipes/index.md
|
|
43
43
|
- Conversation APIs: conversation.md
|
|
44
44
|
- Transports: transports.md
|
|
45
|
+
- Approvals and sandbox: approvals-and-sandbox.md
|
|
45
46
|
- Timeouts, continuation, cancel: timeouts-continuation-cancel.md
|
|
46
47
|
- Threads and configuration: threads-and-config.md
|
|
47
48
|
- Examples: examples.md
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/__init__.py
RENAMED
|
@@ -7,12 +7,20 @@ from .errors import (
|
|
|
7
7
|
CodexTurnInactiveError,
|
|
8
8
|
)
|
|
9
9
|
from .models import (
|
|
10
|
+
ApprovalRequest,
|
|
10
11
|
ApprovalPolicy,
|
|
11
12
|
CancelResult,
|
|
12
13
|
ChatContinuation,
|
|
13
14
|
ChatResult,
|
|
15
|
+
CommandApprovalDecision,
|
|
16
|
+
CommandApprovalRequest,
|
|
17
|
+
CommandApprovalWithExecpolicyAmendment,
|
|
14
18
|
ConversationStep,
|
|
19
|
+
FileChangeApprovalDecision,
|
|
20
|
+
FileChangeApprovalRequest,
|
|
15
21
|
InitializeResult,
|
|
22
|
+
SandboxMode,
|
|
23
|
+
SandboxPolicy,
|
|
16
24
|
ReasoningEffort,
|
|
17
25
|
ReasoningSummary,
|
|
18
26
|
ThreadConfig,
|
|
@@ -22,9 +30,13 @@ from .models import (
|
|
|
22
30
|
|
|
23
31
|
__all__ = [
|
|
24
32
|
"CancelResult",
|
|
33
|
+
"ApprovalRequest",
|
|
25
34
|
"ApprovalPolicy",
|
|
26
35
|
"ChatContinuation",
|
|
27
36
|
"ChatResult",
|
|
37
|
+
"CommandApprovalDecision",
|
|
38
|
+
"CommandApprovalRequest",
|
|
39
|
+
"CommandApprovalWithExecpolicyAmendment",
|
|
28
40
|
"CodexClient",
|
|
29
41
|
"CodexError",
|
|
30
42
|
"CodexProtocolError",
|
|
@@ -32,7 +44,11 @@ __all__ = [
|
|
|
32
44
|
"CodexTransportError",
|
|
33
45
|
"CodexTurnInactiveError",
|
|
34
46
|
"ConversationStep",
|
|
47
|
+
"FileChangeApprovalDecision",
|
|
48
|
+
"FileChangeApprovalRequest",
|
|
35
49
|
"InitializeResult",
|
|
50
|
+
"SandboxMode",
|
|
51
|
+
"SandboxPolicy",
|
|
36
52
|
"ReasoningEffort",
|
|
37
53
|
"ReasoningSummary",
|
|
38
54
|
"ThreadConfig",
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/client.py
RENAMED
|
@@ -6,7 +6,7 @@ import os
|
|
|
6
6
|
import shlex
|
|
7
7
|
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
|
-
from typing import Any, Literal
|
|
9
|
+
from typing import Any, Awaitable, Callable, Coroutine, Literal
|
|
10
10
|
|
|
11
11
|
from .errors import (
|
|
12
12
|
CodexProtocolError,
|
|
@@ -15,10 +15,16 @@ from .errors import (
|
|
|
15
15
|
CodexTurnInactiveError,
|
|
16
16
|
)
|
|
17
17
|
from .models import (
|
|
18
|
+
ApprovalRequest,
|
|
18
19
|
CancelResult,
|
|
19
20
|
ChatContinuation,
|
|
20
21
|
ChatResult,
|
|
22
|
+
CommandApprovalDecision,
|
|
23
|
+
CommandApprovalRequest,
|
|
24
|
+
CommandApprovalWithExecpolicyAmendment,
|
|
21
25
|
ConversationStep,
|
|
26
|
+
FileChangeApprovalDecision,
|
|
27
|
+
FileChangeApprovalRequest,
|
|
22
28
|
InitializeResult,
|
|
23
29
|
ThreadConfig,
|
|
24
30
|
TurnOverrides,
|
|
@@ -32,7 +38,9 @@ from .protocol import (
|
|
|
32
38
|
CONFIG_VALUE_WRITE_METHOD,
|
|
33
39
|
DEFAULT_OPT_OUT_NOTIFICATION_METHODS,
|
|
34
40
|
INITIALIZE_METHOD,
|
|
41
|
+
ITEM_COMMAND_EXECUTION_REQUEST_APPROVAL_METHOD,
|
|
35
42
|
ITEM_COMPLETED_METHOD,
|
|
43
|
+
ITEM_FILE_CHANGE_REQUEST_APPROVAL_METHOD,
|
|
36
44
|
MODEL_LIST_METHOD,
|
|
37
45
|
REVIEW_START_METHOD,
|
|
38
46
|
THREAD_ARCHIVE_METHOD,
|
|
@@ -53,6 +61,7 @@ from .protocol import (
|
|
|
53
61
|
is_turn_completed,
|
|
54
62
|
is_turn_failed,
|
|
55
63
|
make_error_response,
|
|
64
|
+
make_result_response,
|
|
56
65
|
make_request,
|
|
57
66
|
)
|
|
58
67
|
from .transport import StdioTransport, Transport, WebSocketTransport
|
|
@@ -79,6 +88,9 @@ class _TurnSession:
|
|
|
79
88
|
interrupted: bool = False
|
|
80
89
|
|
|
81
90
|
|
|
91
|
+
_APPROVAL_QUEUE_STOP = object()
|
|
92
|
+
|
|
93
|
+
|
|
82
94
|
class ThreadHandle:
|
|
83
95
|
"""Thread-scoped high-level API wrapper bound to one `thread_id`."""
|
|
84
96
|
|
|
@@ -318,6 +330,16 @@ class CodexClient:
|
|
|
318
330
|
self._notifications: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
319
331
|
self._deferred_notifications: list[dict[str, Any]] = []
|
|
320
332
|
self._turn_sessions: dict[str, _TurnSession] = {}
|
|
333
|
+
self._approval_requests: asyncio.Queue[ApprovalRequest | object] = asyncio.Queue()
|
|
334
|
+
self._pending_approval_requests: dict[int | str, ApprovalRequest] = {}
|
|
335
|
+
self._approval_handler: (
|
|
336
|
+
Callable[
|
|
337
|
+
[ApprovalRequest],
|
|
338
|
+
Awaitable[CommandApprovalDecision | FileChangeApprovalDecision],
|
|
339
|
+
]
|
|
340
|
+
| None
|
|
341
|
+
) = None
|
|
342
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
321
343
|
|
|
322
344
|
self._send_lock = asyncio.Lock()
|
|
323
345
|
self._receiver_task: asyncio.Task[None] | None = None
|
|
@@ -457,8 +479,18 @@ class CodexClient:
|
|
|
457
479
|
future.set_exception(CodexTransportError("client is closing"))
|
|
458
480
|
self._pending.clear()
|
|
459
481
|
|
|
482
|
+
for task in list(self._background_tasks):
|
|
483
|
+
task.cancel()
|
|
484
|
+
if self._background_tasks:
|
|
485
|
+
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
|
486
|
+
self._background_tasks.clear()
|
|
487
|
+
|
|
488
|
+
self._pending_approval_requests.clear()
|
|
489
|
+
self._approval_handler = None
|
|
490
|
+
|
|
460
491
|
self._turn_sessions.clear()
|
|
461
492
|
self._deferred_notifications.clear()
|
|
493
|
+
self._approval_requests.put_nowait(_APPROVAL_QUEUE_STOP)
|
|
462
494
|
|
|
463
495
|
await self._transport.close()
|
|
464
496
|
self._started = False
|
|
@@ -557,6 +589,94 @@ class CodexClient:
|
|
|
557
589
|
)
|
|
558
590
|
return response.get("result")
|
|
559
591
|
|
|
592
|
+
def set_approval_handler(
|
|
593
|
+
self,
|
|
594
|
+
handler: (
|
|
595
|
+
Callable[
|
|
596
|
+
[ApprovalRequest],
|
|
597
|
+
Awaitable[CommandApprovalDecision | FileChangeApprovalDecision],
|
|
598
|
+
]
|
|
599
|
+
| None
|
|
600
|
+
),
|
|
601
|
+
) -> None:
|
|
602
|
+
"""Set or clear async handler for v2 approval requests.
|
|
603
|
+
|
|
604
|
+
The handler is invoked for:
|
|
605
|
+
- `item/commandExecution/requestApproval`
|
|
606
|
+
- `item/fileChange/requestApproval`
|
|
607
|
+
|
|
608
|
+
If no handler is configured, requests are auto-declined.
|
|
609
|
+
"""
|
|
610
|
+
self._approval_handler = handler
|
|
611
|
+
|
|
612
|
+
async def approval_requests(self) -> AsyncIterator[ApprovalRequest]:
|
|
613
|
+
"""Yield parsed approval requests from the server.
|
|
614
|
+
|
|
615
|
+
This stream is observational; automatic callback handling (or auto-decline
|
|
616
|
+
default) still applies.
|
|
617
|
+
"""
|
|
618
|
+
while True:
|
|
619
|
+
item = await self._approval_requests.get()
|
|
620
|
+
if item is _APPROVAL_QUEUE_STOP:
|
|
621
|
+
self._approval_requests.put_nowait(_APPROVAL_QUEUE_STOP)
|
|
622
|
+
return
|
|
623
|
+
if isinstance(item, (CommandApprovalRequest, FileChangeApprovalRequest)):
|
|
624
|
+
yield item
|
|
625
|
+
|
|
626
|
+
async def respond_approval(
|
|
627
|
+
self,
|
|
628
|
+
request: ApprovalRequest,
|
|
629
|
+
decision: CommandApprovalDecision | FileChangeApprovalDecision,
|
|
630
|
+
) -> None:
|
|
631
|
+
"""Respond to one pending approval request."""
|
|
632
|
+
pending = self._pending_approval_requests.get(request.request_id)
|
|
633
|
+
if pending is None:
|
|
634
|
+
raise CodexProtocolError("approval request is no longer pending")
|
|
635
|
+
|
|
636
|
+
if type(pending) is not type(request):
|
|
637
|
+
raise CodexProtocolError("approval request type mismatch")
|
|
638
|
+
|
|
639
|
+
self._pending_approval_requests.pop(request.request_id, None)
|
|
640
|
+
result_payload = _encode_approval_result(request, decision)
|
|
641
|
+
response = make_result_response(request.request_id, result_payload)
|
|
642
|
+
async with self._send_lock:
|
|
643
|
+
await self._transport.send(response)
|
|
644
|
+
|
|
645
|
+
async def approve_approval(
|
|
646
|
+
self,
|
|
647
|
+
request: ApprovalRequest,
|
|
648
|
+
*,
|
|
649
|
+
for_session: bool = False,
|
|
650
|
+
execpolicy_amendment: Sequence[str] | None = None,
|
|
651
|
+
) -> None:
|
|
652
|
+
"""Convenience helper to approve an approval request."""
|
|
653
|
+
if isinstance(request, FileChangeApprovalRequest):
|
|
654
|
+
if execpolicy_amendment is not None:
|
|
655
|
+
raise ValueError(
|
|
656
|
+
"execpolicy_amendment is not applicable to file-change approvals"
|
|
657
|
+
)
|
|
658
|
+
decision: FileChangeApprovalDecision = (
|
|
659
|
+
"accept_for_session" if for_session else "accept"
|
|
660
|
+
)
|
|
661
|
+
await self.respond_approval(request, decision)
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
if execpolicy_amendment is not None:
|
|
665
|
+
decision_cmd: CommandApprovalDecision = CommandApprovalWithExecpolicyAmendment(
|
|
666
|
+
execpolicy_amendment=list(execpolicy_amendment)
|
|
667
|
+
)
|
|
668
|
+
else:
|
|
669
|
+
decision_cmd = "accept_for_session" if for_session else "accept"
|
|
670
|
+
await self.respond_approval(request, decision_cmd)
|
|
671
|
+
|
|
672
|
+
async def decline_approval(self, request: ApprovalRequest) -> None:
|
|
673
|
+
"""Convenience helper to decline an approval request and continue turn."""
|
|
674
|
+
await self.respond_approval(request, "decline")
|
|
675
|
+
|
|
676
|
+
async def cancel_approval(self, request: ApprovalRequest) -> None:
|
|
677
|
+
"""Convenience helper to decline an approval request and cancel turn."""
|
|
678
|
+
await self.respond_approval(request, "cancel")
|
|
679
|
+
|
|
560
680
|
async def start_thread(self, config: ThreadConfig | None = None) -> ThreadHandle:
|
|
561
681
|
"""Create a new thread and return a bound handle.
|
|
562
682
|
|
|
@@ -1612,6 +1732,9 @@ class CodexClient:
|
|
|
1612
1732
|
|
|
1613
1733
|
def _cleanup_turn_state(self, turn_id: str) -> None:
|
|
1614
1734
|
self._turn_sessions.pop(turn_id, None)
|
|
1735
|
+
for request_id, request in list(self._pending_approval_requests.items()):
|
|
1736
|
+
if request.turn_id == turn_id:
|
|
1737
|
+
self._pending_approval_requests.pop(request_id, None)
|
|
1615
1738
|
self._drop_deferred_for_turn(turn_id)
|
|
1616
1739
|
|
|
1617
1740
|
def _drop_deferred_for_turn(self, turn_id: str) -> None:
|
|
@@ -1666,6 +1789,14 @@ class CodexClient:
|
|
|
1666
1789
|
if "id" in payload and payload.get("id") is not None:
|
|
1667
1790
|
request_id = payload["id"]
|
|
1668
1791
|
if isinstance(request_id, (int, str)):
|
|
1792
|
+
handled = await self._handle_server_request(
|
|
1793
|
+
request_id=request_id,
|
|
1794
|
+
method=method,
|
|
1795
|
+
payload=payload,
|
|
1796
|
+
)
|
|
1797
|
+
if handled:
|
|
1798
|
+
await self._notifications.put(payload)
|
|
1799
|
+
continue
|
|
1669
1800
|
error_response = make_error_response(
|
|
1670
1801
|
request_id,
|
|
1671
1802
|
-32601,
|
|
@@ -1693,6 +1824,83 @@ class CodexClient:
|
|
|
1693
1824
|
}
|
|
1694
1825
|
)
|
|
1695
1826
|
|
|
1827
|
+
async def _handle_server_request(
|
|
1828
|
+
self,
|
|
1829
|
+
*,
|
|
1830
|
+
request_id: int | str,
|
|
1831
|
+
method: str,
|
|
1832
|
+
payload: dict[str, Any],
|
|
1833
|
+
) -> bool:
|
|
1834
|
+
if method not in {
|
|
1835
|
+
ITEM_COMMAND_EXECUTION_REQUEST_APPROVAL_METHOD,
|
|
1836
|
+
ITEM_FILE_CHANGE_REQUEST_APPROVAL_METHOD,
|
|
1837
|
+
}:
|
|
1838
|
+
return False
|
|
1839
|
+
|
|
1840
|
+
params = payload.get("params")
|
|
1841
|
+
if not isinstance(params, Mapping):
|
|
1842
|
+
error = make_error_response(
|
|
1843
|
+
request_id,
|
|
1844
|
+
-32602,
|
|
1845
|
+
f"{method} received invalid params",
|
|
1846
|
+
)
|
|
1847
|
+
async with self._send_lock:
|
|
1848
|
+
await self._transport.send(error)
|
|
1849
|
+
return True
|
|
1850
|
+
|
|
1851
|
+
try:
|
|
1852
|
+
request = _parse_approval_request(
|
|
1853
|
+
request_id=request_id,
|
|
1854
|
+
method=method,
|
|
1855
|
+
params=params,
|
|
1856
|
+
)
|
|
1857
|
+
except CodexProtocolError as exc:
|
|
1858
|
+
error = make_error_response(request_id, -32602, str(exc))
|
|
1859
|
+
async with self._send_lock:
|
|
1860
|
+
await self._transport.send(error)
|
|
1861
|
+
return True
|
|
1862
|
+
|
|
1863
|
+
self._pending_approval_requests[request_id] = request
|
|
1864
|
+
await self._approval_requests.put(request)
|
|
1865
|
+
|
|
1866
|
+
if self._approval_handler is None:
|
|
1867
|
+
self._spawn_background_task(self._auto_decline_approval(request))
|
|
1868
|
+
else:
|
|
1869
|
+
self._spawn_background_task(self._run_approval_handler(request))
|
|
1870
|
+
return True
|
|
1871
|
+
|
|
1872
|
+
def _spawn_background_task(self, coro: Coroutine[Any, Any, Any]) -> None:
|
|
1873
|
+
task: asyncio.Task[Any] = asyncio.create_task(coro)
|
|
1874
|
+
self._background_tasks.add(task)
|
|
1875
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
1876
|
+
|
|
1877
|
+
async def _auto_decline_approval(self, request: ApprovalRequest) -> None:
|
|
1878
|
+
with contextlib.suppress(CodexProtocolError, CodexTransportError):
|
|
1879
|
+
await self.respond_approval(request, "decline")
|
|
1880
|
+
|
|
1881
|
+
async def _run_approval_handler(self, request: ApprovalRequest) -> None:
|
|
1882
|
+
handler = self._approval_handler
|
|
1883
|
+
if handler is None:
|
|
1884
|
+
await self._auto_decline_approval(request)
|
|
1885
|
+
return
|
|
1886
|
+
|
|
1887
|
+
decision: CommandApprovalDecision | FileChangeApprovalDecision
|
|
1888
|
+
try:
|
|
1889
|
+
decision = await handler(request)
|
|
1890
|
+
except Exception:
|
|
1891
|
+
decision = "decline"
|
|
1892
|
+
|
|
1893
|
+
try:
|
|
1894
|
+
await self.respond_approval(request, decision)
|
|
1895
|
+
except ValueError:
|
|
1896
|
+
with contextlib.suppress(CodexProtocolError, CodexTransportError):
|
|
1897
|
+
await self.respond_approval(request, "decline")
|
|
1898
|
+
except CodexProtocolError:
|
|
1899
|
+
# Already handled (for example by explicit caller response).
|
|
1900
|
+
return
|
|
1901
|
+
except CodexTransportError:
|
|
1902
|
+
return
|
|
1903
|
+
|
|
1696
1904
|
|
|
1697
1905
|
def _is_unset(value: Any) -> bool:
|
|
1698
1906
|
return isinstance(value, UnsetType)
|
|
@@ -2050,6 +2258,100 @@ def _extract_item_text(item: Mapping[str, Any]) -> str | None:
|
|
|
2050
2258
|
return None
|
|
2051
2259
|
|
|
2052
2260
|
|
|
2261
|
+
def _parse_approval_request(
|
|
2262
|
+
*,
|
|
2263
|
+
request_id: int | str,
|
|
2264
|
+
method: str,
|
|
2265
|
+
params: Mapping[str, Any],
|
|
2266
|
+
) -> ApprovalRequest:
|
|
2267
|
+
if method == ITEM_COMMAND_EXECUTION_REQUEST_APPROVAL_METHOD:
|
|
2268
|
+
command_actions_value = params.get("commandActions")
|
|
2269
|
+
command_actions: list[dict[str, Any]] | None = None
|
|
2270
|
+
if isinstance(command_actions_value, list):
|
|
2271
|
+
command_actions = [
|
|
2272
|
+
dict(action) for action in command_actions_value if isinstance(action, Mapping)
|
|
2273
|
+
]
|
|
2274
|
+
|
|
2275
|
+
amendment_value = params.get("proposedExecpolicyAmendment")
|
|
2276
|
+
proposed_execpolicy_amendment: list[str] | None = None
|
|
2277
|
+
if isinstance(amendment_value, list):
|
|
2278
|
+
proposed_execpolicy_amendment = [
|
|
2279
|
+
token for token in amendment_value if isinstance(token, str)
|
|
2280
|
+
]
|
|
2281
|
+
|
|
2282
|
+
return CommandApprovalRequest(
|
|
2283
|
+
request_id=request_id,
|
|
2284
|
+
thread_id=_require_string_field(params, "threadId", method),
|
|
2285
|
+
turn_id=_require_string_field(params, "turnId", method),
|
|
2286
|
+
item_id=_require_string_field(params, "itemId", method),
|
|
2287
|
+
approval_id=_optional_string(params.get("approvalId")),
|
|
2288
|
+
reason=_optional_string(params.get("reason")),
|
|
2289
|
+
command=_optional_string(params.get("command")),
|
|
2290
|
+
cwd=_optional_string(params.get("cwd")),
|
|
2291
|
+
command_actions=command_actions,
|
|
2292
|
+
proposed_execpolicy_amendment=proposed_execpolicy_amendment,
|
|
2293
|
+
)
|
|
2294
|
+
|
|
2295
|
+
if method == ITEM_FILE_CHANGE_REQUEST_APPROVAL_METHOD:
|
|
2296
|
+
return FileChangeApprovalRequest(
|
|
2297
|
+
request_id=request_id,
|
|
2298
|
+
thread_id=_require_string_field(params, "threadId", method),
|
|
2299
|
+
turn_id=_require_string_field(params, "turnId", method),
|
|
2300
|
+
item_id=_require_string_field(params, "itemId", method),
|
|
2301
|
+
grant_root=_optional_string(params.get("grantRoot")),
|
|
2302
|
+
reason=_optional_string(params.get("reason")),
|
|
2303
|
+
)
|
|
2304
|
+
|
|
2305
|
+
raise CodexProtocolError(f"unsupported server request method: {method}")
|
|
2306
|
+
|
|
2307
|
+
|
|
2308
|
+
def _encode_approval_result(
|
|
2309
|
+
request: ApprovalRequest,
|
|
2310
|
+
decision: CommandApprovalDecision | FileChangeApprovalDecision,
|
|
2311
|
+
) -> dict[str, Any]:
|
|
2312
|
+
if isinstance(request, CommandApprovalRequest):
|
|
2313
|
+
if isinstance(decision, CommandApprovalWithExecpolicyAmendment):
|
|
2314
|
+
encoded_decision: Any = {
|
|
2315
|
+
"acceptWithExecpolicyAmendment": {
|
|
2316
|
+
"execpolicy_amendment": list(decision.execpolicy_amendment),
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
else:
|
|
2320
|
+
encoded_decision = _encode_simple_approval_decision(decision)
|
|
2321
|
+
return {"decision": encoded_decision}
|
|
2322
|
+
|
|
2323
|
+
if isinstance(decision, CommandApprovalWithExecpolicyAmendment):
|
|
2324
|
+
raise ValueError("execpolicy amendment decision is invalid for file-change approvals")
|
|
2325
|
+
|
|
2326
|
+
return {"decision": _encode_simple_approval_decision(decision)}
|
|
2327
|
+
|
|
2328
|
+
|
|
2329
|
+
def _encode_simple_approval_decision(
|
|
2330
|
+
decision: str,
|
|
2331
|
+
) -> str:
|
|
2332
|
+
mapping = {
|
|
2333
|
+
"accept": "accept",
|
|
2334
|
+
"accept_for_session": "acceptForSession",
|
|
2335
|
+
"decline": "decline",
|
|
2336
|
+
"cancel": "cancel",
|
|
2337
|
+
}
|
|
2338
|
+
mapped = mapping.get(decision)
|
|
2339
|
+
if mapped is None:
|
|
2340
|
+
raise ValueError(f"unsupported approval decision: {decision!r}")
|
|
2341
|
+
return mapped
|
|
2342
|
+
|
|
2343
|
+
|
|
2344
|
+
def _require_string_field(params: Mapping[str, Any], key: str, method: str) -> str:
|
|
2345
|
+
value = params.get(key)
|
|
2346
|
+
if not isinstance(value, str) or not value:
|
|
2347
|
+
raise CodexProtocolError(f"{method} missing required string field: {key}")
|
|
2348
|
+
return value
|
|
2349
|
+
|
|
2350
|
+
|
|
2351
|
+
def _optional_string(value: Any) -> str | None:
|
|
2352
|
+
return value if isinstance(value, str) else None
|
|
2353
|
+
|
|
2354
|
+
|
|
2053
2355
|
def _event_mentions_turn_id(payload: Any, turn_id: str) -> bool:
|
|
2054
2356
|
"""Return True when payload references target turn id."""
|
|
2055
2357
|
if not isinstance(payload, (dict, list)):
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/models.py
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Any, Literal, TypeAlias
|
|
4
|
+
from typing import Any, Literal, TypeAlias, TypedDict
|
|
5
5
|
|
|
6
6
|
from pydantic import BaseModel, Field
|
|
7
7
|
|
|
@@ -122,6 +122,65 @@ UNSET = UnsetType()
|
|
|
122
122
|
#: - ``"never"``: never request approval.
|
|
123
123
|
ApprovalPolicy: TypeAlias = Literal["untrusted", "on-failure", "on-request", "never"]
|
|
124
124
|
|
|
125
|
+
#: Thread-level sandbox mode accepted by thread/start|resume|fork methods.
|
|
126
|
+
SandboxMode: TypeAlias = Literal["read-only", "workspace-write", "danger-full-access"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class RestrictedReadOnlyAccess(TypedDict, total=False):
|
|
130
|
+
"""Restricted read-only access policy."""
|
|
131
|
+
|
|
132
|
+
type: Literal["restricted"]
|
|
133
|
+
includePlatformDefaults: bool
|
|
134
|
+
readableRoots: list[str]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class FullAccessReadOnlyAccess(TypedDict):
|
|
138
|
+
"""Unrestricted read-only access policy."""
|
|
139
|
+
|
|
140
|
+
type: Literal["fullAccess"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
ReadOnlyAccess: TypeAlias = RestrictedReadOnlyAccess | FullAccessReadOnlyAccess
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class DangerFullAccessSandboxPolicy(TypedDict):
|
|
147
|
+
"""No sandbox restrictions."""
|
|
148
|
+
|
|
149
|
+
type: Literal["dangerFullAccess"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ReadOnlySandboxPolicy(TypedDict, total=False):
|
|
153
|
+
"""Read-only sandbox policy."""
|
|
154
|
+
|
|
155
|
+
type: Literal["readOnly"]
|
|
156
|
+
access: ReadOnlyAccess
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ExternalSandboxPolicy(TypedDict, total=False):
|
|
160
|
+
"""External sandbox policy with explicit network mode."""
|
|
161
|
+
|
|
162
|
+
type: Literal["externalSandbox"]
|
|
163
|
+
networkAccess: Literal["restricted", "enabled"]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class WorkspaceWriteSandboxPolicy(TypedDict, total=False):
|
|
167
|
+
"""Workspace-write sandbox policy."""
|
|
168
|
+
|
|
169
|
+
type: Literal["workspaceWrite"]
|
|
170
|
+
networkAccess: bool
|
|
171
|
+
readOnlyAccess: ReadOnlyAccess
|
|
172
|
+
writableRoots: list[str]
|
|
173
|
+
excludeSlashTmp: bool
|
|
174
|
+
excludeTmpdirEnvVar: bool
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
SandboxPolicy: TypeAlias = (
|
|
178
|
+
DangerFullAccessSandboxPolicy
|
|
179
|
+
| ReadOnlySandboxPolicy
|
|
180
|
+
| ExternalSandboxPolicy
|
|
181
|
+
| WorkspaceWriteSandboxPolicy
|
|
182
|
+
)
|
|
183
|
+
|
|
125
184
|
#: Reasoning effort level for per-turn/model behavior.
|
|
126
185
|
#:
|
|
127
186
|
#: Values are ordered from lowest to highest: ``none``, ``minimal``, ``low``,
|
|
@@ -153,7 +212,8 @@ class ThreadConfig:
|
|
|
153
212
|
model_provider: Optional model provider name/identifier.
|
|
154
213
|
approval_policy: Approval policy mode (`untrusted`, `on-failure`,
|
|
155
214
|
`on-request`, `never`).
|
|
156
|
-
sandbox: Sandbox mode
|
|
215
|
+
sandbox: Sandbox mode selector accepted by the server (`read-only`,
|
|
216
|
+
`workspace-write`, `danger-full-access`).
|
|
157
217
|
personality: Optional personality profile name.
|
|
158
218
|
ephemeral: Optional ephemeral-thread flag.
|
|
159
219
|
config: Optional thread-level config map forwarded to the server.
|
|
@@ -165,7 +225,7 @@ class ThreadConfig:
|
|
|
165
225
|
model: str | None | UnsetType = UNSET
|
|
166
226
|
model_provider: str | None | UnsetType = UNSET
|
|
167
227
|
approval_policy: ApprovalPolicy | None | UnsetType = UNSET
|
|
168
|
-
sandbox:
|
|
228
|
+
sandbox: SandboxMode | None | UnsetType = UNSET
|
|
169
229
|
personality: str | None | UnsetType = UNSET
|
|
170
230
|
ephemeral: bool | None | UnsetType = UNSET
|
|
171
231
|
config: dict[str, Any] | None | UnsetType = UNSET
|
|
@@ -194,7 +254,55 @@ class TurnOverrides:
|
|
|
194
254
|
model: str | None | UnsetType = UNSET
|
|
195
255
|
effort: ReasoningEffort | None | UnsetType = UNSET
|
|
196
256
|
summary: ReasoningSummary | None | UnsetType = UNSET
|
|
197
|
-
sandbox_policy: dict[str, Any] | None | UnsetType = UNSET
|
|
257
|
+
sandbox_policy: SandboxPolicy | dict[str, Any] | None | UnsetType = UNSET
|
|
198
258
|
personality: str | None | UnsetType = UNSET
|
|
199
259
|
approval_policy: ApprovalPolicy | None | UnsetType = UNSET
|
|
200
260
|
output_schema: dict[str, Any] | None | UnsetType = UNSET
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
RequestId: TypeAlias = int | str
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(slots=True)
|
|
267
|
+
class CommandApprovalRequest:
|
|
268
|
+
"""Server-initiated approval request for one command execution item."""
|
|
269
|
+
|
|
270
|
+
request_id: RequestId
|
|
271
|
+
thread_id: str
|
|
272
|
+
turn_id: str
|
|
273
|
+
item_id: str
|
|
274
|
+
approval_id: str | None = None
|
|
275
|
+
reason: str | None = None
|
|
276
|
+
command: str | None = None
|
|
277
|
+
cwd: str | None = None
|
|
278
|
+
command_actions: list[dict[str, Any]] | None = None
|
|
279
|
+
proposed_execpolicy_amendment: list[str] | None = None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(slots=True)
|
|
283
|
+
class FileChangeApprovalRequest:
|
|
284
|
+
"""Server-initiated approval request for one file-change item."""
|
|
285
|
+
|
|
286
|
+
request_id: RequestId
|
|
287
|
+
thread_id: str
|
|
288
|
+
turn_id: str
|
|
289
|
+
item_id: str
|
|
290
|
+
grant_root: str | None = None
|
|
291
|
+
reason: str | None = None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
ApprovalRequest: TypeAlias = CommandApprovalRequest | FileChangeApprovalRequest
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@dataclass(slots=True)
|
|
298
|
+
class CommandApprovalWithExecpolicyAmendment:
|
|
299
|
+
"""Approval decision carrying an execpolicy amendment prefix rule."""
|
|
300
|
+
|
|
301
|
+
execpolicy_amendment: list[str]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
CommandApprovalDecision: TypeAlias = (
|
|
305
|
+
Literal["accept", "accept_for_session", "decline", "cancel"]
|
|
306
|
+
| CommandApprovalWithExecpolicyAmendment
|
|
307
|
+
)
|
|
308
|
+
FileChangeApprovalDecision: TypeAlias = Literal["accept", "accept_for_session", "decline", "cancel"]
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/protocol.py
RENAMED
|
@@ -28,6 +28,8 @@ CONFIG_VALUE_WRITE_METHOD = "config/value/write"
|
|
|
28
28
|
CONFIG_BATCH_WRITE_METHOD = "config/batchWrite"
|
|
29
29
|
CONFIG_REQUIREMENTS_READ_METHOD = "configRequirements/read"
|
|
30
30
|
ITEM_COMPLETED_METHOD = "item/completed"
|
|
31
|
+
ITEM_COMMAND_EXECUTION_REQUEST_APPROVAL_METHOD = "item/commandExecution/requestApproval"
|
|
32
|
+
ITEM_FILE_CHANGE_REQUEST_APPROVAL_METHOD = "item/fileChange/requestApproval"
|
|
31
33
|
|
|
32
34
|
DEFAULT_OPT_OUT_NOTIFICATION_METHODS = (
|
|
33
35
|
"codex/event/agent_message_content_delta",
|
|
@@ -92,6 +94,18 @@ def make_error_response(
|
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
|
|
97
|
+
def make_result_response(
|
|
98
|
+
request_id: int | str,
|
|
99
|
+
result: Any,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""Build a JSON-RPC success response envelope."""
|
|
102
|
+
return {
|
|
103
|
+
"jsonrpc": JSONRPC_VERSION,
|
|
104
|
+
"id": request_id,
|
|
105
|
+
"result": result,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
95
109
|
def is_response_message(payload: dict[str, Any]) -> bool:
|
|
96
110
|
"""Return True when payload is a response (has id, no method)."""
|
|
97
111
|
return "id" in payload and "method" not in payload
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from codex_app_server_sdk import (
|
|
8
|
+
CodexClient,
|
|
9
|
+
CommandApprovalDecision,
|
|
10
|
+
CommandApprovalRequest,
|
|
11
|
+
CommandApprovalWithExecpolicyAmendment,
|
|
12
|
+
FileChangeApprovalDecision,
|
|
13
|
+
FileChangeApprovalRequest,
|
|
14
|
+
)
|
|
15
|
+
from codex_app_server_sdk.transport import Transport
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApprovalTransport(Transport):
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._incoming: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
21
|
+
self.sent: list[dict[str, Any]] = []
|
|
22
|
+
|
|
23
|
+
async def connect(self) -> None:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
async def send(self, payload: Mapping[str, Any]) -> None:
|
|
27
|
+
self.sent.append(dict(payload))
|
|
28
|
+
|
|
29
|
+
async def recv(self) -> dict[str, Any]:
|
|
30
|
+
return await self._incoming.get()
|
|
31
|
+
|
|
32
|
+
async def close(self) -> None:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _wait_for_sent_message(
|
|
37
|
+
transport: ApprovalTransport,
|
|
38
|
+
*,
|
|
39
|
+
timeout: float = 1.0,
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
loop = asyncio.get_running_loop()
|
|
42
|
+
deadline = loop.time() + timeout
|
|
43
|
+
while loop.time() < deadline:
|
|
44
|
+
if transport.sent:
|
|
45
|
+
return transport.sent[-1]
|
|
46
|
+
await asyncio.sleep(0.01)
|
|
47
|
+
raise AssertionError("timed out waiting for transport.send()")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_approval_request_auto_declines_when_no_handler() -> None:
|
|
51
|
+
async def _run() -> None:
|
|
52
|
+
transport = ApprovalTransport()
|
|
53
|
+
client = await CodexClient(transport, request_timeout=1.0).start()
|
|
54
|
+
try:
|
|
55
|
+
await transport._incoming.put(
|
|
56
|
+
{
|
|
57
|
+
"jsonrpc": "2.0",
|
|
58
|
+
"id": 101,
|
|
59
|
+
"method": "item/commandExecution/requestApproval",
|
|
60
|
+
"params": {
|
|
61
|
+
"threadId": "thread-1",
|
|
62
|
+
"turnId": "turn-1",
|
|
63
|
+
"itemId": "item-1",
|
|
64
|
+
"command": "rg TODO",
|
|
65
|
+
"cwd": "/tmp/project",
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
req = await asyncio.wait_for(anext(client.approval_requests()), timeout=1.0)
|
|
71
|
+
assert isinstance(req, CommandApprovalRequest)
|
|
72
|
+
assert req.thread_id == "thread-1"
|
|
73
|
+
assert req.turn_id == "turn-1"
|
|
74
|
+
assert req.item_id == "item-1"
|
|
75
|
+
|
|
76
|
+
response = await _wait_for_sent_message(transport)
|
|
77
|
+
assert response["id"] == 101
|
|
78
|
+
result = response.get("result")
|
|
79
|
+
assert isinstance(result, dict)
|
|
80
|
+
assert result["decision"] == "decline"
|
|
81
|
+
finally:
|
|
82
|
+
await client.close()
|
|
83
|
+
|
|
84
|
+
asyncio.run(_run())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_approval_request_uses_async_handler_decision() -> None:
|
|
88
|
+
async def _run() -> None:
|
|
89
|
+
transport = ApprovalTransport()
|
|
90
|
+
client = await CodexClient(transport, request_timeout=1.0).start()
|
|
91
|
+
try:
|
|
92
|
+
async def _handler(request: Any) -> FileChangeApprovalDecision:
|
|
93
|
+
assert isinstance(request, FileChangeApprovalRequest)
|
|
94
|
+
return "accept_for_session"
|
|
95
|
+
|
|
96
|
+
client.set_approval_handler(_handler)
|
|
97
|
+
|
|
98
|
+
await transport._incoming.put(
|
|
99
|
+
{
|
|
100
|
+
"jsonrpc": "2.0",
|
|
101
|
+
"id": 202,
|
|
102
|
+
"method": "item/fileChange/requestApproval",
|
|
103
|
+
"params": {
|
|
104
|
+
"threadId": "thread-2",
|
|
105
|
+
"turnId": "turn-2",
|
|
106
|
+
"itemId": "item-2",
|
|
107
|
+
"reason": "needs write access",
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
response = await _wait_for_sent_message(transport)
|
|
113
|
+
assert response["id"] == 202
|
|
114
|
+
result = response.get("result")
|
|
115
|
+
assert isinstance(result, dict)
|
|
116
|
+
assert result["decision"] == "acceptForSession"
|
|
117
|
+
finally:
|
|
118
|
+
await client.close()
|
|
119
|
+
|
|
120
|
+
asyncio.run(_run())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_approval_request_encodes_execpolicy_amendment_decision() -> None:
|
|
124
|
+
async def _run() -> None:
|
|
125
|
+
transport = ApprovalTransport()
|
|
126
|
+
client = await CodexClient(transport, request_timeout=1.0).start()
|
|
127
|
+
try:
|
|
128
|
+
async def _handler(request: Any) -> CommandApprovalDecision:
|
|
129
|
+
assert isinstance(request, CommandApprovalRequest)
|
|
130
|
+
return CommandApprovalWithExecpolicyAmendment(["uv", "run"])
|
|
131
|
+
|
|
132
|
+
client.set_approval_handler(_handler)
|
|
133
|
+
|
|
134
|
+
await transport._incoming.put(
|
|
135
|
+
{
|
|
136
|
+
"jsonrpc": "2.0",
|
|
137
|
+
"id": "approval-303",
|
|
138
|
+
"method": "item/commandExecution/requestApproval",
|
|
139
|
+
"params": {
|
|
140
|
+
"threadId": "thread-3",
|
|
141
|
+
"turnId": "turn-3",
|
|
142
|
+
"itemId": "item-3",
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
response = await _wait_for_sent_message(transport)
|
|
148
|
+
assert response["id"] == "approval-303"
|
|
149
|
+
result = response.get("result")
|
|
150
|
+
assert isinstance(result, dict)
|
|
151
|
+
decision = result.get("decision")
|
|
152
|
+
assert isinstance(decision, dict)
|
|
153
|
+
amendment = decision.get("acceptWithExecpolicyAmendment")
|
|
154
|
+
assert isinstance(amendment, dict)
|
|
155
|
+
assert amendment["execpolicy_amendment"] == ["uv", "run"]
|
|
156
|
+
finally:
|
|
157
|
+
await client.close()
|
|
158
|
+
|
|
159
|
+
asyncio.run(_run())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_unknown_server_request_still_returns_method_not_found_error() -> None:
|
|
163
|
+
async def _run() -> None:
|
|
164
|
+
transport = ApprovalTransport()
|
|
165
|
+
client = await CodexClient(transport, request_timeout=1.0).start()
|
|
166
|
+
try:
|
|
167
|
+
await transport._incoming.put(
|
|
168
|
+
{
|
|
169
|
+
"jsonrpc": "2.0",
|
|
170
|
+
"id": 404,
|
|
171
|
+
"method": "unknown/request",
|
|
172
|
+
"params": {},
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
response = await _wait_for_sent_message(transport)
|
|
176
|
+
assert response["id"] == 404
|
|
177
|
+
error = response.get("error")
|
|
178
|
+
assert isinstance(error, dict)
|
|
179
|
+
assert error["code"] == -32601
|
|
180
|
+
finally:
|
|
181
|
+
await client.close()
|
|
182
|
+
|
|
183
|
+
asyncio.run(_run())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/timeouts-continuation-cancel.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/chat_session_websocket.py
RENAMED
|
File without changes
|
|
File without changes
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/thread_concurrent_handles.py
RENAMED
|
File without changes
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/thread_config_and_fork.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/scripts/check_chat_method_links.py
RENAMED
|
File without changes
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/errors.py
RENAMED
|
File without changes
|
|
File without changes
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/transport.py
RENAMED
|
File without changes
|
{codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_chat_assembly_completed_item.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|