From 14323a5185df2bc3b6d05856e59d9808834e0a0d Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 27 May 2026 09:24:10 -0700 Subject: [PATCH 1/3] sdk/python: add friendly sandbox presets --- sdk/python/README.md | 21 +++++- sdk/python/docs/api-reference.md | 42 ++++++++--- sdk/python/docs/faq.md | 16 ++++- sdk/python/docs/getting-started.md | 35 +++++++-- .../13_model_select_and_turn_params/async.py | 11 +-- .../13_model_select_and_turn_params/sync.py | 12 +--- sdk/python/notebooks/sdk_walkthrough.ipynb | 14 ++-- sdk/python/scripts/update_sdk_artifacts.py | 40 ++++++++++- sdk/python/src/openai_codex/__init__.py | 2 + sdk/python/src/openai_codex/_sandbox.py | 72 +++++++++++++++++++ sdk/python/src/openai_codex/api.py | 43 ++++++----- .../tests/test_public_api_runtime_behavior.py | 35 +++++++++ .../tests/test_public_api_signatures.py | 19 +++-- 13 files changed, 288 insertions(+), 74 deletions(-) create mode 100644 sdk/python/src/openai_codex/_sandbox.py diff --git a/sdk/python/README.md b/sdk/python/README.md index 5b8e6b8178c2..a3cd6d1dca52 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -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) result = thread.run("Say hello in one sentence.") print(result.final_response) print(len(result.items)) @@ -38,6 +38,23 @@ 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) + thread.run("Make the requested change.") + review = thread.run("Review the diff only.", sandbox=Sandbox.read_only) +``` + +Available presets are `Sandbox.read_only`, `Sandbox.workspace`, and +`Sandbox.full_access`. 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: diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index db78ff5f1dd6..2d358206f9d2 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -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) + result = thread.run("Review the diff only.", sandbox=Sandbox.read_only) +``` + +Presets: + +- `Sandbox.read_only` +- `Sandbox.workspace` +- `Sandbox.full_access` + +A sandbox passed to `run(...)` or `turn(...)` applies to that turn and +subsequent turns. + ## TurnHandle / AsyncTurnHandle ### TurnHandle diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index 5f2ed87ffddd..79f4e87fc937 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -48,7 +48,21 @@ 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) +result = thread.run("Review only.", sandbox=Sandbox.read_only) +``` + +The presets are `Sandbox.read_only`, `Sandbox.workspace`, and +`Sandbox.full_access`. A turn sandbox override applies to that turn and +subsequent turns. ## Why only `thread_start(...)` and `thread_resume(...)`? diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index b21db2a20760..4cf10031ca4d 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -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, + ) result = thread.run("Say hello in one sentence.") print("Thread:", thread.id) @@ -76,7 +80,24 @@ 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) + thread.run("Make the requested changes.") + review = thread.run("Review the diff only.", sandbox=Sandbox.read_only) +``` + +Available presets are `Sandbox.read_only`, `Sandbox.workspace`, and +`Sandbox.full_access`. 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 +112,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 +132,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 +145,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 +154,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` diff --git a/sdk/python/examples/13_model_select_and_turn_params/async.py b/sdk/python/examples/13_model_select_and_turn_params/async.py index 144169c46c19..d9e706f2409d 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/async.py +++ b/sdk/python/examples/13_model_select_and_turn_params/async.py @@ -13,12 +13,12 @@ from openai_codex import ( AsyncCodex, + Sandbox, ) from openai_codex.types import ( Personality, ReasoningEffort, ReasoningSummary, - SandboxPolicy, ) REASONING_RANK = { @@ -67,13 +67,6 @@ def _pick_highest_turn_effort(model) -> ReasoningEffort: "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() diff --git a/sdk/python/examples/13_model_select_and_turn_params/sync.py b/sdk/python/examples/13_model_select_and_turn_params/sync.py index 962665363fb0..4a9afa09805a 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/sync.py +++ b/sdk/python/examples/13_model_select_and_turn_params/sync.py @@ -11,12 +11,12 @@ from openai_codex import ( Codex, + Sandbox, ) from openai_codex.types import ( Personality, ReasoningEffort, ReasoningSummary, - SandboxPolicy, ) REASONING_RANK = { @@ -65,14 +65,6 @@ def _pick_highest_turn_effort(model) -> ReasoningEffort: "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 @@ def _pick_highest_turn_effort(model) -> ReasoningEffort: 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() diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index bd313a02b193..1bb9bf731f60 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -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", diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index e88c45521fff..b20f814f662a 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -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( diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index 3bc018a76962..9f1ca770c557 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -15,6 +15,7 @@ LocalImageInput, MentionInput, RunInput, + Sandbox, SkillInput, TextInput, Thread, @@ -44,6 +45,7 @@ "Codex", "AsyncCodex", "ApprovalMode", + "Sandbox", "ChatgptLoginHandle", "DeviceCodeLoginHandle", "AsyncChatgptLoginHandle", diff --git a/sdk/python/src/openai_codex/_sandbox.py b/sdk/python/src/openai_codex/_sandbox.py new file mode 100644 index 000000000000..7e476ad95219 --- /dev/null +++ b/sdk/python/src/openai_codex/_sandbox.py @@ -0,0 +1,72 @@ +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): + """High-level sandbox presets for threads and turns.""" + + read_only = "read-only" + workspace = "workspace" + 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: + 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: + 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}") diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index f14a3117890e..6b6ddf617e59 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -37,6 +37,7 @@ _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 @@ Personality, ReasoningEffort, ReasoningSummary, - SandboxMode, - SandboxPolicy, SortDirection, ThreadArchiveResponse, ThreadCompactStartResponse, @@ -138,7 +137,7 @@ def thread_start( 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 @@ def thread_start( 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 @@ def thread_resume( 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 @@ def thread_resume( 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 @@ def thread_fork( 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 @@ def thread_fork( 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 @@ async def thread_start( 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 @@ async def thread_start( 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 @@ async def thread_resume( 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 @@ async def thread_resume( 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 @@ async def thread_fork( 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 @@ async def thread_fork( 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 @@ def run( 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 @@ def run( model=model, output_schema=output_schema, personality=personality, - sandbox_policy=sandbox_policy, + sandbox=sandbox, service_tier=service_tier, summary=summary, ) @@ -562,7 +561,7 @@ def turn( 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 @@ def turn( 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 @@ async def run( 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 @@ async def run( model=model, output_schema=output_schema, personality=personality, - sandbox_policy=sandbox_policy, + sandbox=sandbox, service_tier=service_tier, summary=summary, ) @@ -645,7 +644,7 @@ async def turn( 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 @@ async def turn( model=model, output_schema=output_schema, personality=personality, - sandbox_policy=sandbox_policy, + sandbox_policy=_sandbox_policy(sandbox), service_tier=service_tier, summary=summary, ) diff --git a/sdk/python/tests/test_public_api_runtime_behavior.py b/sdk/python/tests/test_public_api_runtime_behavior.py index 4e79cb61043b..3efff8c862b3 100644 --- a/sdk/python/tests/test_public_api_runtime_behavior.py +++ b/sdk/python/tests/test_public_api_runtime_behavior.py @@ -11,6 +11,7 @@ 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": "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": { + "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"): + 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", diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index 9a1212dfdf62..d6f784d25d0b 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -16,6 +16,7 @@ AsyncThread, AsyncTurnHandle, Codex, + Sandbox, Thread, TurnHandle, TurnResult, @@ -29,6 +30,7 @@ "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", "workspace"), + ("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", ], From c0aff145b1f0a507205cfdd95afb24b948556f00 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 27 May 2026 09:27:44 -0700 Subject: [PATCH 2/3] sdk/python: clarify workspace write sandbox preset --- sdk/python/README.md | 15 ++++++++++----- sdk/python/docs/api-reference.md | 8 ++++---- sdk/python/docs/faq.md | 12 ++++++++---- sdk/python/docs/getting-started.md | 14 +++++++++----- sdk/python/src/openai_codex/_sandbox.py | 13 +++++++++---- .../tests/test_public_api_runtime_behavior.py | 6 +++--- sdk/python/tests/test_public_api_signatures.py | 2 +- 7 files changed, 44 insertions(+), 26 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index a3cd6d1dca52..d9fdc02db427 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -28,7 +28,7 @@ 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", sandbox=Sandbox.workspace) + 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)) @@ -46,14 +46,19 @@ Use the same enum when creating a thread or changing its sandbox for a turn: from openai_codex import Codex, Sandbox with Codex() as codex: - thread = codex.thread_start(sandbox=Sandbox.workspace) + 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 are `Sandbox.read_only`, `Sandbox.workspace`, and -`Sandbox.full_access`. A sandbox passed to `run(...)` or `turn(...)` applies -to that turn and subsequent turns on the thread. +Available presets: + +- `Sandbox.read_only`: read files without allowing writes. +- `Sandbox.workspace_write`: read files and write inside the workspace and configured writable roots. +- `Sandbox.full_access`: run without filesystem access restrictions. + +A sandbox passed to `run(...)` or `turn(...)` applies to that turn and +subsequent turns on the thread. ## Login diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 2d358206f9d2..e039cce7f75b 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -191,15 +191,15 @@ Use `sandbox=` consistently on thread lifecycle methods and turns: from openai_codex import Codex, Sandbox with Codex() as codex: - thread = codex.thread_start(sandbox=Sandbox.workspace) + thread = codex.thread_start(sandbox=Sandbox.workspace_write) result = thread.run("Review the diff only.", sandbox=Sandbox.read_only) ``` Presets: -- `Sandbox.read_only` -- `Sandbox.workspace` -- `Sandbox.full_access` +- `Sandbox.read_only`: read files without allowing writes. +- `Sandbox.workspace_write`: read files and write inside the workspace and configured writable roots. +- `Sandbox.full_access`: run without filesystem access restrictions. A sandbox passed to `run(...)` or `turn(...)` applies to that turn and subsequent turns. diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index 79f4e87fc937..f8684d89f877 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -56,13 +56,17 @@ Use the same `sandbox=` keyword for threads and turns: ```python from openai_codex import Sandbox -thread = codex.thread_start(sandbox=Sandbox.workspace) +thread = codex.thread_start(sandbox=Sandbox.workspace_write) result = thread.run("Review only.", sandbox=Sandbox.read_only) ``` -The presets are `Sandbox.read_only`, `Sandbox.workspace`, and -`Sandbox.full_access`. A turn sandbox override applies to that turn and -subsequent turns. +The presets are: + +- `Sandbox.read_only`: read files without allowing writes. +- `Sandbox.workspace_write`: read files and write inside the workspace and configured writable roots. +- `Sandbox.full_access`: run without filesystem access restrictions. + +A turn sandbox override applies to that turn and subsequent turns. ## Why only `thread_start(...)` and `thread_resume(...)`? diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 4cf10031ca4d..1e6bca09aed1 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -61,7 +61,7 @@ with Codex() as codex: thread = codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"}, - sandbox=Sandbox.workspace, + sandbox=Sandbox.workspace_write, ) result = thread.run("Say hello in one sentence.") @@ -88,14 +88,18 @@ Use one enum for the initial sandbox and for later turn overrides: from openai_codex import Codex, Sandbox with Codex() as codex: - thread = codex.thread_start(sandbox=Sandbox.workspace) + 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 are `Sandbox.read_only`, `Sandbox.workspace`, and -`Sandbox.full_access`. A turn override also becomes the sandbox for subsequent -turns on that thread. +Available presets: + +- `Sandbox.read_only`: read files without allowing writes. +- `Sandbox.workspace_write`: read files and write inside the workspace and configured writable roots. +- `Sandbox.full_access`: run without filesystem access restrictions. + +A turn override also becomes the sandbox for subsequent turns on that thread. ## 5) Continue the same thread (multi-turn) diff --git a/sdk/python/src/openai_codex/_sandbox.py b/sdk/python/src/openai_codex/_sandbox.py index 7e476ad95219..fd1cafa3e6f9 100644 --- a/sdk/python/src/openai_codex/_sandbox.py +++ b/sdk/python/src/openai_codex/_sandbox.py @@ -13,10 +13,15 @@ class Sandbox(str, Enum): - """High-level sandbox presets for threads and turns.""" + """Preset filesystem access levels for threads and turns. + + `read_only` allows file reads without writes. `workspace_write` allows + writes inside the workspace and configured writable roots. `full_access` + removes filesystem access restrictions. + """ read_only = "read-only" - workspace = "workspace" + workspace_write = "workspace-write" full_access = "full-access" @@ -36,7 +41,7 @@ def _sandbox_mode(sandbox: Sandbox | None) -> SandboxMode | None: match sandbox: case Sandbox.read_only: return SandboxMode.read_only - case Sandbox.workspace: + case Sandbox.workspace_write: return SandboxMode.workspace_write case Sandbox.full_access: return SandboxMode.danger_full_access @@ -55,7 +60,7 @@ def _sandbox_policy(sandbox: Sandbox | None) -> SandboxPolicy | None: return SandboxPolicy( root=ReadOnlySandboxPolicy(type="readOnly"), ) - case Sandbox.workspace: + case Sandbox.workspace_write: return SandboxPolicy( root=WorkspaceWriteSandboxPolicy(type="workspaceWrite"), ) diff --git a/sdk/python/tests/test_public_api_runtime_behavior.py b/sdk/python/tests/test_public_api_runtime_behavior.py index 3efff8c862b3..f6d83a965eae 100644 --- a/sdk/python/tests/test_public_api_runtime_behavior.py +++ b/sdk/python/tests/test_public_api_runtime_behavior.py @@ -165,7 +165,7 @@ def test_sandbox_presets_serialize_for_threads_and_turns() -> None: sandbox.name: public_api_module._sandbox_mode(sandbox).value for sandbox in Sandbox } == { "read_only": "read-only", - "workspace": "workspace-write", + "workspace_write": "workspace-write", "full_access": "danger-full-access", } assert { @@ -176,7 +176,7 @@ def test_sandbox_presets_serialize_for_threads_and_turns() -> None: for sandbox in Sandbox } == { "read_only": {"networkAccess": False, "type": "readOnly"}, - "workspace": { + "workspace_write": { "excludeSlashTmp": False, "excludeTmpdirEnvVar": False, "networkAccess": False, @@ -189,7 +189,7 @@ def test_sandbox_presets_serialize_for_threads_and_turns() -> None: 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"): + with pytest.raises(ValueError, match="Sandbox\\.workspace_write"): public_api_module._sandbox_mode("workspace") # type: ignore[arg-type] diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index d6f784d25d0b..b4dc49acf111 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -197,7 +197,7 @@ 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", "workspace"), + ("workspace_write", "workspace-write"), ("full_access", "full-access"), ] From 76d350255197f56ce9740ed2d56db074ec50f359 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 27 May 2026 09:29:17 -0700 Subject: [PATCH 3/3] sdk/python: document default workspace write sandbox --- sdk/python/README.md | 7 ++++--- sdk/python/docs/api-reference.md | 6 +++--- sdk/python/docs/faq.md | 5 +++-- sdk/python/docs/getting-started.md | 5 +++-- sdk/python/src/openai_codex/_sandbox.py | 3 ++- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index d9fdc02db427..72290f38e047 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -54,11 +54,12 @@ with Codex() as codex: Available presets: - `Sandbox.read_only`: read files without allowing writes. -- `Sandbox.workspace_write`: read files and write inside the workspace and configured writable roots. +- `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. -A sandbox passed to `run(...)` or `turn(...)` applies to that turn and -subsequent turns on the thread. +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 diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index e039cce7f75b..a353d32fa2e2 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -198,11 +198,11 @@ with Codex() as codex: Presets: - `Sandbox.read_only`: read files without allowing writes. -- `Sandbox.workspace_write`: read files and write inside the workspace and configured writable roots. +- `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. -A sandbox passed to `run(...)` or `turn(...)` applies to that turn and -subsequent turns. +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 diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index f8684d89f877..42f2cb1f6a03 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -63,10 +63,11 @@ result = thread.run("Review only.", sandbox=Sandbox.read_only) The presets are: - `Sandbox.read_only`: read files without allowing writes. -- `Sandbox.workspace_write`: read files and write inside the workspace and configured writable roots. +- `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. -A turn sandbox override applies to that turn and subsequent turns. +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(...)`? diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 1e6bca09aed1..6113b9050a43 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -96,10 +96,11 @@ with Codex() as codex: Available presets: - `Sandbox.read_only`: read files without allowing writes. -- `Sandbox.workspace_write`: read files and write inside the workspace and configured writable roots. +- `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. -A turn override also becomes the sandbox for subsequent turns on that thread. +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) diff --git a/sdk/python/src/openai_codex/_sandbox.py b/sdk/python/src/openai_codex/_sandbox.py index fd1cafa3e6f9..24aeb57c858f 100644 --- a/sdk/python/src/openai_codex/_sandbox.py +++ b/sdk/python/src/openai_codex/_sandbox.py @@ -15,7 +15,8 @@ class Sandbox(str, Enum): """Preset filesystem access levels for threads and turns. - `read_only` allows file reads without writes. `workspace_write` allows + `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. """