Skip to content

Commit b1cbf62

Browse files
authored
[codex] Add friendly Python SDK sandbox presets (openai#24772)
## Why The Python SDK currently exposes sandbox selection differently depending on where it is used: thread lifecycle methods accept `SandboxMode`, while turns accept the lower-level `SandboxPolicy` shape. For the common case of choosing an access level, that leaks app-server wire details into otherwise straightforward SDK usage. This makes the common path explicit and discoverable: callers choose a named sandbox preset once, using the same keyword on threads and turns. The preset name `workspace_write` also makes the granted capability clear at the callsite. ## What changed - Added a root-level `Sandbox` enum with documented presets: - `Sandbox.read_only`: read files without allowing writes. - `Sandbox.workspace_write`: the normal default for projects with a recorded trust decision; read files and write inside the workspace and configured writable roots. - `Sandbox.full_access`: run without filesystem access restrictions. - Documented that omitting `sandbox=` delegates to app-server's configured default, while explicit turn overrides remain sticky for subsequent turns. - Updated sync and async thread lifecycle and turn APIs to consistently accept `sandbox=Sandbox...`, translating to the existing app-server thread and turn representations internally. - Updated the public API artifact generator so regenerated SDK wrappers retain the friendly enum shape. - Replaced low-level policy construction in Python docs, examples, and the walkthrough notebook with the preset API. - Added focused coverage for root exports, method signatures, preset-to-wire mapping, and rejection of raw string sandbox inputs. ## API impact High-level turn calls now use `sandbox=` instead of `sandbox_policy=`: ```python from openai_codex import Codex, Sandbox with Codex() as codex: thread = codex.thread_start(sandbox=Sandbox.workspace_write) result = thread.run("Review the diff only.", sandbox=Sandbox.read_only) ``` `thread_start(...)` already defaults to `ApprovalMode.auto_review`, so normal writable usage is concise: ```python with Codex() as codex: thread = codex.thread_start(sandbox=Sandbox.workspace_write) thread.run("Update the files in this workspace.") ``` With that combination, edits inside `cwd` and configured writable roots run within the workspace-write sandbox. Operations that require approval, such as edits outside those roots, are routed through auto review. When `sandbox=` is omitted, app-server resolves its configured default. A sandbox supplied to `run(...)` or `turn(...)` applies to that turn and subsequent turns. ## Test coverage - `sdk/python/tests/test_public_api_signatures.py` covers the public export and parameter names, including the default approval mode. - `sdk/python/tests/test_public_api_runtime_behavior.py` covers preset mappings to the existing wire types and raw string rejection.
1 parent bee7880 commit b1cbf62

13 files changed

Lines changed: 310 additions & 74 deletions

File tree

‎sdk/python/README.md‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ when you intentionally want to run against a specific local app-server binary.
2323
## Quickstart
2424

2525
```python
26-
from openai_codex import Codex
26+
from openai_codex import Codex, Sandbox
2727

2828
with Codex() as codex:
2929
# Call login_api_key(...) first when this app-server session is not
3030
# already authenticated.
31-
thread = codex.thread_start(model="gpt-5")
31+
thread = codex.thread_start(model="gpt-5", sandbox=Sandbox.workspace_write)
3232
result = thread.run("Say hello in one sentence.")
3333
print(result.final_response)
3434
print(len(result.items))
@@ -38,6 +38,29 @@ with Codex() as codex:
3838
`final_response` is `None` when the turn completes without a final-answer or
3939
phase-less assistant message item.
4040

41+
## Sandbox
42+
43+
Use the same enum when creating a thread or changing its sandbox for a turn:
44+
45+
```python
46+
from openai_codex import Codex, Sandbox
47+
48+
with Codex() as codex:
49+
thread = codex.thread_start(sandbox=Sandbox.workspace_write)
50+
thread.run("Make the requested change.")
51+
review = thread.run("Review the diff only.", sandbox=Sandbox.read_only)
52+
```
53+
54+
Available presets:
55+
56+
- `Sandbox.read_only`: read files without allowing writes.
57+
- `Sandbox.workspace_write`: the normal default for projects with a recorded trust decision; read files and write inside the workspace and configured writable roots.
58+
- `Sandbox.full_access`: run without filesystem access restrictions.
59+
60+
When `sandbox=` is omitted, app-server uses its configured default. A sandbox
61+
passed to `run(...)` or `turn(...)` applies to that turn and subsequent turns
62+
on the thread.
63+
4164
## Login
4265

4366
Use the auth helper that matches your app:

‎sdk/python/docs/api-reference.md‎

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ from openai_codex import (
1212
Codex,
1313
AsyncCodex,
1414
ApprovalMode,
15+
Sandbox,
1516
ChatgptLoginHandle,
1617
DeviceCodeLoginHandle,
1718
AsyncChatgptLoginHandle,
@@ -63,10 +64,10 @@ Properties/methods:
6364
- `login_chatgpt_device_code() -> DeviceCodeLoginHandle`
6465
- `account(*, refresh_token: bool = False) -> GetAccountResponse`
6566
- `logout() -> None`
66-
- `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread`
67+
- `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None) -> Thread`
6768
- `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> ThreadListResponse`
68-
- `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread`
69-
- `thread_fork(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, sandbox=None) -> Thread`
69+
- `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None) -> Thread`
70+
- `thread_fork(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, sandbox: Sandbox | None = None) -> Thread`
7071
- `thread_archive(thread_id: str) -> ThreadArchiveResponse`
7172
- `thread_unarchive(thread_id: str) -> Thread`
7273
- `models(*, include_hidden: bool = False) -> ModelListResponse`
@@ -103,10 +104,10 @@ Properties/methods:
103104
- `login_chatgpt_device_code() -> Awaitable[AsyncDeviceCodeLoginHandle]`
104105
- `account(*, refresh_token: bool = False) -> Awaitable[GetAccountResponse]`
105106
- `logout() -> Awaitable[None]`
106-
- `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]`
107+
- `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None) -> Awaitable[AsyncThread]`
107108
- `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> Awaitable[ThreadListResponse]`
108-
- `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]`
109-
- `thread_fork(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, sandbox=None) -> Awaitable[AsyncThread]`
109+
- `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None) -> Awaitable[AsyncThread]`
110+
- `thread_fork(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, sandbox: Sandbox | None = None) -> Awaitable[AsyncThread]`
110111
- `thread_archive(thread_id: str) -> Awaitable[ThreadArchiveResponse]`
111112
- `thread_unarchive(thread_id: str) -> Awaitable[AsyncThread]`
112113
- `models(*, include_hidden: bool = False) -> Awaitable[ModelListResponse]`
@@ -148,16 +149,16 @@ attempt. API-key login completes synchronously and does not return a handle.
148149

149150
### Thread
150151

151-
- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnResult`
152-
- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnHandle`
152+
- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnResult`
153+
- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnHandle`
153154
- `read(*, include_turns: bool = False) -> ThreadReadResponse`
154155
- `set_name(name: str) -> ThreadSetNameResponse`
155156
- `compact() -> ThreadCompactStartResponse`
156157

157158
### AsyncThread
158159

159-
- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[TurnResult]`
160-
- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]`
160+
- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[TurnResult]`
161+
- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]`
161162
- `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]`
162163
- `set_name(name: str) -> Awaitable[ThreadSetNameResponse]`
163164
- `compact() -> Awaitable[ThreadCompactStartResponse]`
@@ -182,6 +183,27 @@ phase-less assistant message item.
182183
Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`,
183184
`interrupt()`) before collecting the turn result.
184185

186+
## Sandbox
187+
188+
Use `sandbox=` consistently on thread lifecycle methods and turns:
189+
190+
```python
191+
from openai_codex import Codex, Sandbox
192+
193+
with Codex() as codex:
194+
thread = codex.thread_start(sandbox=Sandbox.workspace_write)
195+
result = thread.run("Review the diff only.", sandbox=Sandbox.read_only)
196+
```
197+
198+
Presets:
199+
200+
- `Sandbox.read_only`: read files without allowing writes.
201+
- `Sandbox.workspace_write`: the normal default for projects with a recorded trust decision; read files and write inside the workspace and configured writable roots.
202+
- `Sandbox.full_access`: run without filesystem access restrictions.
203+
204+
When `sandbox=` is omitted, app-server uses its configured default. A sandbox
205+
passed to `run(...)` or `turn(...)` applies to that turn and subsequent turns.
206+
185207
## TurnHandle / AsyncTurnHandle
186208

187209
### TurnHandle

‎sdk/python/docs/faq.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,26 @@ If you are migrating older code, update these names:
4848
- `sortKey` -> `sort_key`
4949
- `sourceKinds` -> `source_kinds`
5050
- `outputSchema` -> `output_schema`
51-
- `sandboxPolicy` -> `sandbox_policy`
51+
52+
## How do I choose sandbox access?
53+
54+
Use the same `sandbox=` keyword for threads and turns:
55+
56+
```python
57+
from openai_codex import Sandbox
58+
59+
thread = codex.thread_start(sandbox=Sandbox.workspace_write)
60+
result = thread.run("Review only.", sandbox=Sandbox.read_only)
61+
```
62+
63+
The presets are:
64+
65+
- `Sandbox.read_only`: read files without allowing writes.
66+
- `Sandbox.workspace_write`: the normal default for projects with a recorded trust decision; read files and write inside the workspace and configured writable roots.
67+
- `Sandbox.full_access`: run without filesystem access restrictions.
68+
69+
When `sandbox=` is omitted, app-server uses its configured default. A turn
70+
sandbox override applies to that turn and subsequent turns.
5271

5372
## Why only `thread_start(...)` and `thread_resume(...)`?
5473

‎sdk/python/docs/getting-started.md‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Existing Codex auth state is reused automatically. To authenticate from the SDK,
2626
use the flow that fits your app:
2727

2828
```python
29-
from openai_codex import Codex
29+
from openai_codex import Codex, Sandbox
3030

3131
with Codex() as codex:
3232
codex.login_api_key("sk-...")
@@ -58,7 +58,11 @@ with Codex() as codex:
5858
server = codex.metadata.serverInfo
5959
print("Server:", None if server is None else server.name, None if server is None else server.version)
6060

61-
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
61+
thread = codex.thread_start(
62+
model="gpt-5.4",
63+
config={"model_reasoning_effort": "high"},
64+
sandbox=Sandbox.workspace_write,
65+
)
6266
result = thread.run("Say hello in one sentence.")
6367

6468
print("Thread:", thread.id)
@@ -76,7 +80,29 @@ What happened:
7680
- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, or interrupting before collecting `TurnResult`
7781
- one client can consume multiple active turns concurrently; turn streams are routed by turn ID
7882

79-
## 4) Continue the same thread (multi-turn)
83+
## 4) Change sandbox access
84+
85+
Use one enum for the initial sandbox and for later turn overrides:
86+
87+
```python
88+
from openai_codex import Codex, Sandbox
89+
90+
with Codex() as codex:
91+
thread = codex.thread_start(sandbox=Sandbox.workspace_write)
92+
thread.run("Make the requested changes.")
93+
review = thread.run("Review the diff only.", sandbox=Sandbox.read_only)
94+
```
95+
96+
Available presets:
97+
98+
- `Sandbox.read_only`: read files without allowing writes.
99+
- `Sandbox.workspace_write`: the normal default for projects with a recorded trust decision; read files and write inside the workspace and configured writable roots.
100+
- `Sandbox.full_access`: run without filesystem access restrictions.
101+
102+
When `sandbox=` is omitted, app-server uses its configured default. A turn
103+
override also becomes the sandbox for subsequent turns on that thread.
104+
105+
## 5) Continue the same thread (multi-turn)
80106

81107
```python
82108
from openai_codex import Codex
@@ -91,7 +117,7 @@ with Codex() as codex:
91117
print("second:", second.final_response)
92118
```
93119

94-
## 5) Async parity
120+
## 6) Async parity
95121

96122
Use `async with AsyncCodex()` as the normal async entrypoint. `AsyncCodex`
97123
initializes lazily, and context entry makes startup/shutdown explicit.
@@ -111,7 +137,7 @@ async def main() -> None:
111137
asyncio.run(main())
112138
```
113139

114-
## 6) Resume an existing thread
140+
## 7) Resume an existing thread
115141

116142
```python
117143
from openai_codex import Codex
@@ -124,7 +150,7 @@ with Codex() as codex:
124150
print(result.final_response)
125151
```
126152

127-
## 7) Public app-server types
153+
## 8) Public app-server types
128154

129155
The convenience wrappers live at the package root. Public app-server value and
130156
event types live under:
@@ -133,7 +159,7 @@ event types live under:
133159
from openai_codex.types import ThreadReadResponse, Turn, TurnStatus
134160
```
135161

136-
## 8) Next stops
162+
## 9) Next stops
137163

138164
- API surface and signatures: `docs/api-reference.md`
139165
- Common decisions/pitfalls: `docs/faq.md`

‎sdk/python/examples/13_model_select_and_turn_params/async.py‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313

1414
from openai_codex import (
1515
AsyncCodex,
16+
Sandbox,
1617
)
1718
from openai_codex.types import (
1819
Personality,
1920
ReasoningEffort,
2021
ReasoningSummary,
21-
SandboxPolicy,
2222
)
2323

2424
REASONING_RANK = {
@@ -67,13 +67,6 @@ def _pick_highest_turn_effort(model) -> ReasoningEffort:
6767
"additionalProperties": False,
6868
}
6969

70-
SANDBOX_POLICY = SandboxPolicy.model_validate(
71-
{
72-
"type": "readOnly",
73-
"access": {"type": "fullAccess"},
74-
}
75-
)
76-
7770

7871
async def main() -> None:
7972
async with AsyncCodex(config=runtime_config()) as codex:
@@ -106,7 +99,7 @@ async def main() -> None:
10699
model=selected_model.model,
107100
output_schema=OUTPUT_SCHEMA,
108101
personality=Personality.pragmatic,
109-
sandbox_policy=SANDBOX_POLICY,
102+
sandbox=Sandbox.read_only,
110103
summary=ReasoningSummary.model_validate("concise"),
111104
)
112105
second = await second_turn.run()

‎sdk/python/examples/13_model_select_and_turn_params/sync.py‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111

1212
from openai_codex import (
1313
Codex,
14+
Sandbox,
1415
)
1516
from openai_codex.types import (
1617
Personality,
1718
ReasoningEffort,
1819
ReasoningSummary,
19-
SandboxPolicy,
2020
)
2121

2222
REASONING_RANK = {
@@ -65,14 +65,6 @@ def _pick_highest_turn_effort(model) -> ReasoningEffort:
6565
"additionalProperties": False,
6666
}
6767

68-
SANDBOX_POLICY = SandboxPolicy.model_validate(
69-
{
70-
"type": "readOnly",
71-
"access": {"type": "fullAccess"},
72-
}
73-
)
74-
75-
7668
with Codex(config=runtime_config()) as codex:
7769
models = codex.models(include_hidden=True)
7870
selected_model = _pick_highest_model(models.data)
@@ -102,7 +94,7 @@ def _pick_highest_turn_effort(model) -> ReasoningEffort:
10294
model=selected_model.model,
10395
output_schema=OUTPUT_SCHEMA,
10496
personality=Personality.pragmatic,
105-
sandbox_policy=SANDBOX_POLICY,
97+
sandbox=Sandbox.read_only,
10698
summary=ReasoningSummary.model_validate("concise"),
10799
).run()
108100

‎sdk/python/notebooks/sdk_walkthrough.ipynb‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -220,11 +220,11 @@
220220
"source": [
221221
"# Cell 5b: one turn with most optional turn params\n",
222222
"from pathlib import Path\n",
223-
"from openai_codex import (\n",
223+
"from openai_codex import Sandbox\n",
224+
"from openai_codex.types import (\n",
224225
" Personality,\n",
225226
" ReasoningEffort,\n",
226227
" ReasoningSummary,\n",
227-
" SandboxPolicy,\n",
228228
")\n",
229229
"\n",
230230
"output_schema = {\n",
@@ -237,7 +237,6 @@
237237
" 'additionalProperties': False,\n",
238238
"}\n",
239239
"\n",
240-
"sandbox_policy = SandboxPolicy.model_validate({'type': 'readOnly', 'access': {'type': 'fullAccess'}})\n",
241240
"summary = ReasoningSummary.model_validate('concise')\n",
242241
"\n",
243242
"with Codex() as codex:\n",
@@ -249,7 +248,7 @@
249248
" model='gpt-5.4',\n",
250249
" output_schema=output_schema,\n",
251250
" personality=Personality.pragmatic,\n",
252-
" sandbox_policy=sandbox_policy,\n",
251+
" sandbox=Sandbox.read_only,\n",
253252
" summary=summary,\n",
254253
" )\n",
255254
" result = turn.run()\n",
@@ -266,11 +265,11 @@
266265
"source": [
267266
"# Cell 5c: choose highest model + highest supported reasoning, then run turns\n",
268267
"from pathlib import Path\n",
269-
"from openai_codex import (\n",
268+
"from openai_codex import Sandbox\n",
269+
"from openai_codex.types import (\n",
270270
" Personality,\n",
271271
" ReasoningEffort,\n",
272272
" ReasoningSummary,\n",
273-
" SandboxPolicy,\n",
274273
")\n",
275274
"\n",
276275
"reasoning_rank = {\n",
@@ -310,7 +309,6 @@
310309
" 'required': ['summary', 'actions'],\n",
311310
" 'additionalProperties': False,\n",
312311
"}\n",
313-
"sandbox_policy = SandboxPolicy.model_validate({'type': 'readOnly', 'access': {'type': 'fullAccess'}})\n",
314312
"\n",
315313
"with Codex() as codex:\n",
316314
" models = codex.models(include_hidden=True)\n",
@@ -337,7 +335,7 @@
337335
" model=selected_model.model,\n",
338336
" output_schema=output_schema,\n",
339337
" personality=Personality.pragmatic,\n",
340-
" sandbox_policy=sandbox_policy,\n",
338+
" sandbox=Sandbox.read_only,\n",
341339
" summary=ReasoningSummary.model_validate('concise'),\n",
342340
" ).run()\n",
343341
" print('agent.message.params:', second.final_response)\n",

0 commit comments

Comments
 (0)