[codex] Add friendly Python SDK sandbox presets (#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.
This commit is contained in:
Ahmed Ibrahim
2026-05-27 11:11:04 -07:00
committed by GitHub
Unverified
parent bee78806a9
commit b1cbf622ad
13 changed files with 310 additions and 74 deletions
+25 -2
View File
@@ -23,12 +23,12 @@ when you intentionally want to run against a specific local app-server binary.
## Quickstart
```python
from openai_codex import Codex
from openai_codex import Codex, Sandbox
with Codex() as codex:
# Call login_api_key(...) first when this app-server session is not
# already authenticated.
thread = codex.thread_start(model="gpt-5")
thread = codex.thread_start(model="gpt-5", sandbox=Sandbox.workspace_write)
result = thread.run("Say hello in one sentence.")
print(result.final_response)
print(len(result.items))
@@ -38,6 +38,29 @@ with Codex() as codex:
`final_response` is `None` when the turn completes without a final-answer or
phase-less assistant message item.
## Sandbox
Use the same enum when creating a thread or changing its sandbox for a turn:
```python
from openai_codex import Codex, Sandbox
with Codex() as codex:
thread = codex.thread_start(sandbox=Sandbox.workspace_write)
thread.run("Make the requested change.")
review = thread.run("Review the diff only.", sandbox=Sandbox.read_only)
```
Available 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.
When `sandbox=` is omitted, app-server uses its configured default. A sandbox
passed to `run(...)` or `turn(...)` applies to that turn and subsequent turns
on the thread.
## Login
Use the auth helper that matches your app:
+32 -10
View File
@@ -12,6 +12,7 @@ from openai_codex import (
Codex,
AsyncCodex,
ApprovalMode,
Sandbox,
ChatgptLoginHandle,
DeviceCodeLoginHandle,
AsyncChatgptLoginHandle,
@@ -63,10 +64,10 @@ Properties/methods:
- `login_chatgpt_device_code() -> DeviceCodeLoginHandle`
- `account(*, refresh_token: bool = False) -> GetAccountResponse`
- `logout() -> None`
- `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`
- `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`
- `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> ThreadListResponse`
- `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`
- `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`
- `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`
- `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`
- `thread_archive(thread_id: str) -> ThreadArchiveResponse`
- `thread_unarchive(thread_id: str) -> Thread`
- `models(*, include_hidden: bool = False) -> ModelListResponse`
@@ -103,10 +104,10 @@ Properties/methods:
- `login_chatgpt_device_code() -> Awaitable[AsyncDeviceCodeLoginHandle]`
- `account(*, refresh_token: bool = False) -> Awaitable[GetAccountResponse]`
- `logout() -> Awaitable[None]`
- `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]`
- `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]`
- `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> Awaitable[ThreadListResponse]`
- `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]`
- `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]`
- `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]`
- `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]`
- `thread_archive(thread_id: str) -> Awaitable[ThreadArchiveResponse]`
- `thread_unarchive(thread_id: str) -> Awaitable[AsyncThread]`
- `models(*, include_hidden: bool = False) -> Awaitable[ModelListResponse]`
@@ -148,16 +149,16 @@ attempt. API-key login completes synchronously and does not return a handle.
### Thread
- `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`
- `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`
- `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`
- `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`
- `read(*, include_turns: bool = False) -> ThreadReadResponse`
- `set_name(name: str) -> ThreadSetNameResponse`
- `compact() -> ThreadCompactStartResponse`
### AsyncThread
- `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]`
- `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]`
- `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]`
- `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]`
- `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]`
- `set_name(name: str) -> Awaitable[ThreadSetNameResponse]`
- `compact() -> Awaitable[ThreadCompactStartResponse]`
@@ -182,6 +183,27 @@ phase-less assistant message item.
Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`,
`interrupt()`) before collecting the turn result.
## Sandbox
Use `sandbox=` consistently on thread lifecycle methods and turns:
```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)
```
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.
When `sandbox=` is omitted, app-server uses its configured default. A sandbox
passed to `run(...)` or `turn(...)` applies to that turn and subsequent turns.
## TurnHandle / AsyncTurnHandle
### TurnHandle
+20 -1
View File
@@ -48,7 +48,26 @@ If you are migrating older code, update these names:
- `sortKey` -> `sort_key`
- `sourceKinds` -> `source_kinds`
- `outputSchema` -> `output_schema`
- `sandboxPolicy` -> `sandbox_policy`
## How do I choose sandbox access?
Use the same `sandbox=` keyword for threads and turns:
```python
from openai_codex import Sandbox
thread = codex.thread_start(sandbox=Sandbox.workspace_write)
result = thread.run("Review only.", sandbox=Sandbox.read_only)
```
The presets are:
- `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.
When `sandbox=` is omitted, app-server uses its configured default. A turn
sandbox override applies to that turn and subsequent turns.
## Why only `thread_start(...)` and `thread_resume(...)`?
+33 -7
View File
@@ -26,7 +26,7 @@ Existing Codex auth state is reused automatically. To authenticate from the SDK,
use the flow that fits your app:
```python
from openai_codex import Codex
from openai_codex import Codex, Sandbox
with Codex() as codex:
codex.login_api_key("sk-...")
@@ -58,7 +58,11 @@ with Codex() as codex:
server = codex.metadata.serverInfo
print("Server:", None if server is None else server.name, None if server is None else server.version)
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
thread = codex.thread_start(
model="gpt-5.4",
config={"model_reasoning_effort": "high"},
sandbox=Sandbox.workspace_write,
)
result = thread.run("Say hello in one sentence.")
print("Thread:", thread.id)
@@ -76,7 +80,29 @@ What happened:
- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, or interrupting before collecting `TurnResult`
- one client can consume multiple active turns concurrently; turn streams are routed by turn ID
## 4) Continue the same thread (multi-turn)
## 4) Change sandbox access
Use one enum for the initial sandbox and for later turn overrides:
```python
from openai_codex import Codex, Sandbox
with Codex() as codex:
thread = codex.thread_start(sandbox=Sandbox.workspace_write)
thread.run("Make the requested changes.")
review = thread.run("Review the diff only.", sandbox=Sandbox.read_only)
```
Available 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.
When `sandbox=` is omitted, app-server uses its configured default. A turn
override also becomes the sandbox for subsequent turns on that thread.
## 5) Continue the same thread (multi-turn)
```python
from openai_codex import Codex
@@ -91,7 +117,7 @@ with Codex() as codex:
print("second:", second.final_response)
```
## 5) Async parity
## 6) Async parity
Use `async with AsyncCodex()` as the normal async entrypoint. `AsyncCodex`
initializes lazily, and context entry makes startup/shutdown explicit.
@@ -111,7 +137,7 @@ async def main() -> None:
asyncio.run(main())
```
## 6) Resume an existing thread
## 7) Resume an existing thread
```python
from openai_codex import Codex
@@ -124,7 +150,7 @@ with Codex() as codex:
print(result.final_response)
```
## 7) Public app-server types
## 8) Public app-server types
The convenience wrappers live at the package root. Public app-server value and
event types live under:
@@ -133,7 +159,7 @@ event types live under:
from openai_codex.types import ThreadReadResponse, Turn, TurnStatus
```
## 8) Next stops
## 9) Next stops
- API surface and signatures: `docs/api-reference.md`
- Common decisions/pitfalls: `docs/faq.md`
@@ -13,12 +13,12 @@ import asyncio
from openai_codex import (
AsyncCodex,
Sandbox,
)
from openai_codex.types import (
Personality,
ReasoningEffort,
ReasoningSummary,
SandboxPolicy,
)
REASONING_RANK = {
@@ -67,13 +67,6 @@ OUTPUT_SCHEMA = {
"additionalProperties": False,
}
SANDBOX_POLICY = SandboxPolicy.model_validate(
{
"type": "readOnly",
"access": {"type": "fullAccess"},
}
)
async def main() -> None:
async with AsyncCodex(config=runtime_config()) as codex:
@@ -106,7 +99,7 @@ async def main() -> None:
model=selected_model.model,
output_schema=OUTPUT_SCHEMA,
personality=Personality.pragmatic,
sandbox_policy=SANDBOX_POLICY,
sandbox=Sandbox.read_only,
summary=ReasoningSummary.model_validate("concise"),
)
second = await second_turn.run()
@@ -11,12 +11,12 @@ ensure_local_sdk_src()
from openai_codex import (
Codex,
Sandbox,
)
from openai_codex.types import (
Personality,
ReasoningEffort,
ReasoningSummary,
SandboxPolicy,
)
REASONING_RANK = {
@@ -65,14 +65,6 @@ OUTPUT_SCHEMA = {
"additionalProperties": False,
}
SANDBOX_POLICY = SandboxPolicy.model_validate(
{
"type": "readOnly",
"access": {"type": "fullAccess"},
}
)
with Codex(config=runtime_config()) as codex:
models = codex.models(include_hidden=True)
selected_model = _pick_highest_model(models.data)
@@ -102,7 +94,7 @@ with Codex(config=runtime_config()) as codex:
model=selected_model.model,
output_schema=OUTPUT_SCHEMA,
personality=Personality.pragmatic,
sandbox_policy=SANDBOX_POLICY,
sandbox=Sandbox.read_only,
summary=ReasoningSummary.model_validate("concise"),
).run()
+6 -8
View File
@@ -220,11 +220,11 @@
"source": [
"# Cell 5b: one turn with most optional turn params\n",
"from pathlib import Path\n",
"from openai_codex import (\n",
"from openai_codex import Sandbox\n",
"from openai_codex.types import (\n",
" Personality,\n",
" ReasoningEffort,\n",
" ReasoningSummary,\n",
" SandboxPolicy,\n",
")\n",
"\n",
"output_schema = {\n",
@@ -237,7 +237,6 @@
" 'additionalProperties': False,\n",
"}\n",
"\n",
"sandbox_policy = SandboxPolicy.model_validate({'type': 'readOnly', 'access': {'type': 'fullAccess'}})\n",
"summary = ReasoningSummary.model_validate('concise')\n",
"\n",
"with Codex() as codex:\n",
@@ -249,7 +248,7 @@
" model='gpt-5.4',\n",
" output_schema=output_schema,\n",
" personality=Personality.pragmatic,\n",
" sandbox_policy=sandbox_policy,\n",
" sandbox=Sandbox.read_only,\n",
" summary=summary,\n",
" )\n",
" result = turn.run()\n",
@@ -266,11 +265,11 @@
"source": [
"# Cell 5c: choose highest model + highest supported reasoning, then run turns\n",
"from pathlib import Path\n",
"from openai_codex import (\n",
"from openai_codex import Sandbox\n",
"from openai_codex.types import (\n",
" Personality,\n",
" ReasoningEffort,\n",
" ReasoningSummary,\n",
" SandboxPolicy,\n",
")\n",
"\n",
"reasoning_rank = {\n",
@@ -310,7 +309,6 @@
" 'required': ['summary', 'actions'],\n",
" 'additionalProperties': False,\n",
"}\n",
"sandbox_policy = SandboxPolicy.model_validate({'type': 'readOnly', 'access': {'type': 'fullAccess'}})\n",
"\n",
"with Codex() as codex:\n",
" models = codex.models(include_hidden=True)\n",
@@ -337,7 +335,7 @@
" model=selected_model.model,\n",
" output_schema=output_schema,\n",
" personality=Personality.pragmatic,\n",
" sandbox_policy=sandbox_policy,\n",
" sandbox=Sandbox.read_only,\n",
" summary=ReasoningSummary.model_validate('concise'),\n",
" ).run()\n",
" print('agent.message.params:', second.final_response)\n",
+39 -1
View File
@@ -875,7 +875,41 @@ def _approval_mode_model_arg_lines(*, indent: str = " ") -> list[str]
def _model_arg_lines(fields: list[PublicFieldSpec], *, indent: str = " ") -> list[str]:
return [f"{indent}{field.wire_name}={field.py_name}," for field in fields]
lines: list[str] = []
for field in fields:
arg = field.py_name
if field.wire_name == "sandbox":
arg = "_sandbox_mode(sandbox)"
elif field.wire_name == "sandbox_policy":
arg = "_sandbox_policy(sandbox)"
lines.append(f"{indent}{field.wire_name}={arg},")
return lines
def _replace_public_sandbox_field(
fields: list[PublicFieldSpec], *, wire_name: str
) -> list[PublicFieldSpec]:
"""Expose stable wire sandbox settings through one public enum parameter."""
public_fields: list[PublicFieldSpec] = []
replaced = False
for field in fields:
if field.wire_name != wire_name:
public_fields.append(field)
continue
if replaced:
raise RuntimeError(f"Found more than one generated sandbox field named {wire_name}")
public_fields.append(
PublicFieldSpec(
wire_name=wire_name,
py_name="sandbox",
annotation="Sandbox | None",
required=False,
)
)
replaced = True
if not replaced:
raise RuntimeError(f"Could not find generated sandbox field named {wire_name}")
return public_fields
def _replace_generated_block(source: str, block_name: str, body: str) -> str:
@@ -1113,6 +1147,7 @@ def generate_public_api_flat_methods() -> None:
"ThreadStartParams",
exclude=approval_fields,
)
thread_start_fields = _replace_public_sandbox_field(thread_start_fields, wire_name="sandbox")
thread_list_fields = _load_public_fields(
"openai_codex.generated.v2_all",
"ThreadListParams",
@@ -1122,16 +1157,19 @@ def generate_public_api_flat_methods() -> None:
"ThreadResumeParams",
exclude={"thread_id", *approval_fields},
)
thread_resume_fields = _replace_public_sandbox_field(thread_resume_fields, wire_name="sandbox")
thread_fork_fields = _load_public_fields(
"openai_codex.generated.v2_all",
"ThreadForkParams",
exclude={"thread_id", *approval_fields},
)
thread_fork_fields = _replace_public_sandbox_field(thread_fork_fields, wire_name="sandbox")
turn_start_fields = _load_public_fields(
"openai_codex.generated.v2_all",
"TurnStartParams",
exclude={"thread_id", "input", *approval_fields},
)
turn_start_fields = _replace_public_sandbox_field(turn_start_fields, wire_name="sandbox_policy")
source = public_api_path.read_text()
source = _replace_generated_block(
+2
View File
@@ -15,6 +15,7 @@ from .api import (
LocalImageInput,
MentionInput,
RunInput,
Sandbox,
SkillInput,
TextInput,
Thread,
@@ -44,6 +45,7 @@ __all__ = [
"Codex",
"AsyncCodex",
"ApprovalMode",
"Sandbox",
"ChatgptLoginHandle",
"DeviceCodeLoginHandle",
"AsyncChatgptLoginHandle",
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from enum import Enum
from typing import NoReturn
from .generated.v2_all import (
DangerFullAccessSandboxPolicy,
ReadOnlySandboxPolicy,
SandboxMode,
SandboxPolicy,
WorkspaceWriteSandboxPolicy,
)
class Sandbox(str, Enum):
"""Preset filesystem access levels for threads and turns.
`read_only` allows file reads without writes. `workspace_write` is the
normal default for projects with a recorded trust decision and allows
writes inside the workspace and configured writable roots. `full_access`
removes filesystem access restrictions.
"""
read_only = "read-only"
workspace_write = "workspace-write"
full_access = "full-access"
def _require_sandbox(sandbox: Sandbox) -> None:
if isinstance(sandbox, Sandbox):
return
options = ", ".join(f"Sandbox.{value.name}" for value in Sandbox)
raise ValueError(f"sandbox must be one of: {options}")
def _sandbox_mode(sandbox: Sandbox | None) -> SandboxMode | None:
"""Translate a public preset to the thread lifecycle wire mode."""
if sandbox is None:
return None
_require_sandbox(sandbox)
match sandbox:
case Sandbox.read_only:
return SandboxMode.read_only
case Sandbox.workspace_write:
return SandboxMode.workspace_write
case Sandbox.full_access:
return SandboxMode.danger_full_access
case _:
return _assert_never_sandbox(sandbox)
def _sandbox_policy(sandbox: Sandbox | None) -> SandboxPolicy | None:
"""Translate a public preset to the turn override wire policy."""
if sandbox is None:
return None
_require_sandbox(sandbox)
match sandbox:
case Sandbox.read_only:
return SandboxPolicy(
root=ReadOnlySandboxPolicy(type="readOnly"),
)
case Sandbox.workspace_write:
return SandboxPolicy(
root=WorkspaceWriteSandboxPolicy(type="workspaceWrite"),
)
case Sandbox.full_access:
return SandboxPolicy(
root=DangerFullAccessSandboxPolicy(type="dangerFullAccess"),
)
case _:
return _assert_never_sandbox(sandbox)
def _assert_never_sandbox(sandbox: NoReturn) -> NoReturn:
"""Make sandbox mapping exhaustive for static type checkers."""
raise AssertionError(f"Unhandled sandbox: {sandbox!r}")
+21 -22
View File
@@ -37,6 +37,7 @@ from ._run import (
_collect_async_turn_result,
_collect_turn_result,
)
from ._sandbox import Sandbox as Sandbox, _sandbox_mode, _sandbox_policy
from .async_client import AsyncAppServerClient
from .client import AppServerClient, AppServerConfig
from .generated.v2_all import (
@@ -48,8 +49,6 @@ from .generated.v2_all import (
Personality,
ReasoningEffort,
ReasoningSummary,
SandboxMode,
SandboxPolicy,
SortDirection,
ThreadArchiveResponse,
ThreadCompactStartResponse,
@@ -138,7 +137,7 @@ class Codex:
model: str | None = None,
model_provider: str | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
sandbox: Sandbox | None = None,
service_name: str | None = None,
service_tier: str | None = None,
session_start_source: ThreadStartSource | None = None,
@@ -156,7 +155,7 @@ class Codex:
model=model,
model_provider=model_provider,
personality=personality,
sandbox=sandbox,
sandbox=_sandbox_mode(sandbox),
service_name=service_name,
service_tier=service_tier,
session_start_source=session_start_source,
@@ -205,7 +204,7 @@ class Codex:
model: str | None = None,
model_provider: str | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
sandbox: Sandbox | None = None,
service_tier: str | None = None,
) -> Thread:
approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode)
@@ -220,7 +219,7 @@ class Codex:
model=model,
model_provider=model_provider,
personality=personality,
sandbox=sandbox,
sandbox=_sandbox_mode(sandbox),
service_tier=service_tier,
)
resumed = self._client.thread_resume(thread_id, params)
@@ -238,7 +237,7 @@ class Codex:
ephemeral: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
sandbox: SandboxMode | None = None,
sandbox: Sandbox | None = None,
service_tier: str | None = None,
thread_source: ThreadSource | None = None,
) -> Thread:
@@ -254,7 +253,7 @@ class Codex:
ephemeral=ephemeral,
model=model,
model_provider=model_provider,
sandbox=sandbox,
sandbox=_sandbox_mode(sandbox),
service_tier=service_tier,
thread_source=thread_source,
)
@@ -371,7 +370,7 @@ class AsyncCodex:
model: str | None = None,
model_provider: str | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
sandbox: Sandbox | None = None,
service_name: str | None = None,
service_tier: str | None = None,
session_start_source: ThreadStartSource | None = None,
@@ -390,7 +389,7 @@ class AsyncCodex:
model=model,
model_provider=model_provider,
personality=personality,
sandbox=sandbox,
sandbox=_sandbox_mode(sandbox),
service_name=service_name,
service_tier=service_tier,
session_start_source=session_start_source,
@@ -440,7 +439,7 @@ class AsyncCodex:
model: str | None = None,
model_provider: str | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
sandbox: Sandbox | None = None,
service_tier: str | None = None,
) -> AsyncThread:
await self._ensure_initialized()
@@ -456,7 +455,7 @@ class AsyncCodex:
model=model,
model_provider=model_provider,
personality=personality,
sandbox=sandbox,
sandbox=_sandbox_mode(sandbox),
service_tier=service_tier,
)
resumed = await self._client.thread_resume(thread_id, params)
@@ -474,7 +473,7 @@ class AsyncCodex:
ephemeral: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
sandbox: SandboxMode | None = None,
sandbox: Sandbox | None = None,
service_tier: str | None = None,
thread_source: ThreadSource | None = None,
) -> AsyncThread:
@@ -491,7 +490,7 @@ class AsyncCodex:
ephemeral=ephemeral,
model=model,
model_provider=model_provider,
sandbox=sandbox,
sandbox=_sandbox_mode(sandbox),
service_tier=service_tier,
thread_source=thread_source,
)
@@ -529,7 +528,7 @@ class Thread:
model: str | None = None,
output_schema: JsonObject | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
sandbox: Sandbox | None = None,
service_tier: str | None = None,
summary: ReasoningSummary | None = None,
) -> TurnResult:
@@ -541,7 +540,7 @@ class Thread:
model=model,
output_schema=output_schema,
personality=personality,
sandbox_policy=sandbox_policy,
sandbox=sandbox,
service_tier=service_tier,
summary=summary,
)
@@ -562,7 +561,7 @@ class Thread:
model: str | None = None,
output_schema: JsonObject | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
sandbox: Sandbox | None = None,
service_tier: str | None = None,
summary: ReasoningSummary | None = None,
) -> TurnHandle:
@@ -578,7 +577,7 @@ class Thread:
model=model,
output_schema=output_schema,
personality=personality,
sandbox_policy=sandbox_policy,
sandbox_policy=_sandbox_policy(sandbox),
service_tier=service_tier,
summary=summary,
)
@@ -612,7 +611,7 @@ class AsyncThread:
model: str | None = None,
output_schema: JsonObject | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
sandbox: Sandbox | None = None,
service_tier: str | None = None,
summary: ReasoningSummary | None = None,
) -> TurnResult:
@@ -624,7 +623,7 @@ class AsyncThread:
model=model,
output_schema=output_schema,
personality=personality,
sandbox_policy=sandbox_policy,
sandbox=sandbox,
service_tier=service_tier,
summary=summary,
)
@@ -645,7 +644,7 @@ class AsyncThread:
model: str | None = None,
output_schema: JsonObject | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
sandbox: Sandbox | None = None,
service_tier: str | None = None,
summary: ReasoningSummary | None = None,
) -> AsyncTurnHandle:
@@ -662,7 +661,7 @@ class AsyncThread:
model=model,
output_schema=output_schema,
personality=personality,
sandbox_policy=sandbox_policy,
sandbox_policy=_sandbox_policy(sandbox),
service_tier=service_tier,
summary=summary,
)
@@ -11,6 +11,7 @@ from openai_codex.api import (
ApprovalMode,
AsyncCodex,
Codex,
Sandbox,
)
from openai_codex.generated.v2_all import TurnStartParams
from openai_codex.models import InitializeResponse
@@ -158,6 +159,40 @@ def test_unknown_approval_mode_is_rejected() -> None:
public_api_module._approval_mode_settings("allow_all") # type: ignore[arg-type]
def test_sandbox_presets_serialize_for_threads_and_turns() -> None:
"""One public sandbox enum should map to both stable wire representations."""
assert {
sandbox.name: public_api_module._sandbox_mode(sandbox).value for sandbox in Sandbox
} == {
"read_only": "read-only",
"workspace_write": "workspace-write",
"full_access": "danger-full-access",
}
assert {
sandbox.name: public_api_module._sandbox_policy(sandbox).model_dump(
by_alias=True,
mode="json",
)
for sandbox in Sandbox
} == {
"read_only": {"networkAccess": False, "type": "readOnly"},
"workspace_write": {
"excludeSlashTmp": False,
"excludeTmpdirEnvVar": False,
"networkAccess": False,
"type": "workspaceWrite",
"writableRoots": [],
},
"full_access": {"type": "dangerFullAccess"},
}
def test_raw_sandbox_strings_are_rejected() -> None:
"""Callers should use the discoverable enum rather than memorizing values."""
with pytest.raises(ValueError, match="Sandbox\\.workspace_write"):
public_api_module._sandbox_mode("workspace") # type: ignore[arg-type]
def test_retry_examples_compare_status_with_enum() -> None:
for path in (
ROOT / "examples" / "10_error_handling_and_retry" / "sync.py",
+15 -4
View File
@@ -16,6 +16,7 @@ from openai_codex import (
AsyncThread,
AsyncTurnHandle,
Codex,
Sandbox,
Thread,
TurnHandle,
TurnResult,
@@ -29,6 +30,7 @@ EXPECTED_ROOT_EXPORTS = [
"Codex",
"AsyncCodex",
"ApprovalMode",
"Sandbox",
"ChatgptLoginHandle",
"DeviceCodeLoginHandle",
"AsyncChatgptLoginHandle",
@@ -191,6 +193,15 @@ def test_root_exports_approval_mode() -> None:
]
def test_root_exports_sandbox_presets() -> None:
"""The friendly sandbox API should expose only obvious named presets."""
assert [(sandbox.name, sandbox.value) for sandbox in Sandbox] == [
("read_only", "read-only"),
("workspace_write", "workspace-write"),
("full_access", "full-access"),
]
def test_package_and_default_client_versions_follow_project_version() -> None:
"""The importable package version should stay aligned with pyproject metadata."""
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
@@ -341,7 +352,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"model",
"output_schema",
"personality",
"sandbox_policy",
"sandbox",
"service_tier",
"summary",
],
@@ -352,7 +363,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"model",
"output_schema",
"personality",
"sandbox_policy",
"sandbox",
"service_tier",
"summary",
],
@@ -416,7 +427,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"model",
"output_schema",
"personality",
"sandbox_policy",
"sandbox",
"service_tier",
"summary",
],
@@ -427,7 +438,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"model",
"output_schema",
"personality",
"sandbox_policy",
"sandbox",
"service_tier",
"summary",
],