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.
Files changed (52) hide show
  1. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/PKG-INFO +1 -1
  2. codex_app_server_sdk-0.3.2/docs/approvals-and-sandbox.md +117 -0
  3. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/index.md +26 -2
  4. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/recipes/index.md +1 -0
  5. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/mkdocs.yml +1 -0
  6. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/pyproject.toml +1 -1
  7. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/__init__.py +16 -0
  8. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/client.py +303 -1
  9. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/models.py +112 -4
  10. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/protocol.py +14 -0
  11. codex_app_server_sdk-0.3.2/tests/test_approval_requests.py +183 -0
  12. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/uv.lock +1 -1
  13. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/.github/workflows/docs.yml +0 -0
  14. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/.github/workflows/publish.yml +0 -0
  15. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/.gitignore +0 -0
  16. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/LICENSE +0 -0
  17. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/README.md +0 -0
  18. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/RELEASE.md +0 -0
  19. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/client.md +0 -0
  20. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/errors.md +0 -0
  21. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/index.md +0 -0
  22. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/models.md +0 -0
  23. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/package.md +0 -0
  24. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/protocol.md +0 -0
  25. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/api/transport.md +0 -0
  26. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/behavior-guarantees.md +0 -0
  27. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/conversation.md +0 -0
  28. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/examples.md +0 -0
  29. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/getting-started.md +0 -0
  30. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/protocol-mapping.md +0 -0
  31. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/threads-and-config.md +0 -0
  32. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/timeouts-continuation-cancel.md +0 -0
  33. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/docs/transports.md +0 -0
  34. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/chat_session_stdio.py +0 -0
  35. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/chat_session_websocket.py +0 -0
  36. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/chat_steps_rich.py +0 -0
  37. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/thread_concurrent_handles.py +0 -0
  38. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/thread_config_and_fork.py +0 -0
  39. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/thread_ops_showcase.py +0 -0
  40. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/examples/thread_resume_by_id.py +0 -0
  41. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/scripts/check_chat_method_links.py +0 -0
  42. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/errors.py +0 -0
  43. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/py.typed +0 -0
  44. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/src/codex_app_server_sdk/transport.py +0 -0
  45. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_chat_assembly_completed_item.py +0 -0
  46. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_chat_continuation.py +0 -0
  47. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_chat_steps.py +0 -0
  48. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_client_chat_once.py +0 -0
  49. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_client_lifecycle.py +0 -0
  50. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_protocol.py +0 -0
  51. {codex_app_server_sdk-0.3.1 → codex_app_server_sdk-0.3.2}/tests/test_thread_handle.py +0 -0
  52. {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.1
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 sync
32
+ uv add codex-app-server-sdk
25
33
  ```
26
34
 
27
- ## Local docs workflow
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-app-server-sdk"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  description = "Async Python client for Codex app-server over stdio and websocket."
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -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",
@@ -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)):
@@ -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/policy selector accepted by the server.
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: str | None | UnsetType = UNSET
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"]
@@ -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())
@@ -186,7 +186,7 @@ wheels = [
186
186
 
187
187
  [[package]]
188
188
  name = "codex-app-server-sdk"
189
- version = "0.3.0"
189
+ version = "0.3.2"
190
190
  source = { editable = "." }
191
191
  dependencies = [
192
192
  { name = "pydantic" },