豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 0f01c8d

Browse files
Merge branch 'main' into fix/assistant-history-status
2 parents d96a8f5 + e80d2d2 commit 0f01c8d

40 files changed

+2819
-826
lines changed

docs/ja/sandbox/guide.md

Lines changed: 202 additions & 187 deletions
Large diffs are not rendered by default.

docs/ko/sandbox/guide.md

Lines changed: 181 additions & 166 deletions
Large diffs are not rendered by default.

docs/sandbox/guide.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ Prefer built-in capabilities when they fit. Write a custom capability only when
216216

217217
### Manifest
218218

219-
A [`Manifest`][agents.sandbox.manifest.Manifest] describes the workspace for a fresh sandbox session. It can set the workspace `root`, declare files and directories, copy in local files, clone Git repos, attach remote storage mounts, set environment variables, and define users or groups.
219+
A [`Manifest`][agents.sandbox.manifest.Manifest] describes the workspace for a fresh sandbox session. It can set the workspace `root`, declare files and directories, copy in local files, clone Git repos, attach remote storage mounts, set environment variables, define users or groups, and grant access to specific absolute paths outside the workspace.
220220

221221
Manifest entry paths are workspace-relative. They cannot be absolute paths or escape the workspace with `..`, which keeps the workspace contract portable across local, Docker, and hosted clients.
222222

@@ -237,6 +237,21 @@ Mount entries describe what storage to expose; mount strategies describe how a s
237237

238238
Good manifest design usually means keeping the workspace contract narrow, putting long task recipes in workspace files such as `repo/task.md`, and using relative workspace paths in instructions, for example `repo/task.md` or `output/report.md`. If the agent edits files with the `Filesystem` capability's `apply_patch` tool, remember that patch paths are relative to the sandbox workspace root, not the shell `workdir`.
239239

240+
Use `extra_path_grants` only when the agent needs a concrete absolute path outside the workspace, such as `/tmp` for temporary tool output or `/opt/toolchain` for a read-only runtime. A grant applies to both SDK file APIs and shell execution where the backend can enforce filesystem policy:
241+
242+
```python
243+
from agents.sandbox import Manifest, SandboxPathGrant
244+
245+
manifest = Manifest(
246+
extra_path_grants=(
247+
SandboxPathGrant(path="/tmp"),
248+
SandboxPathGrant(path="/opt/toolchain", read_only=True),
249+
),
250+
)
251+
```
252+
253+
Snapshots and `persist_workspace()` still include only the workspace root. Extra granted paths are runtime access, not durable workspace state.
254+
240255
### Permissions
241256

242257
`Permissions` controls filesystem permissions for manifest entries. It is about the files the sandbox materializes, not model permissions, approval policy, or API credentials.

docs/zh/sandbox/guide.md

Lines changed: 203 additions & 188 deletions
Large diffs are not rendered by default.

examples/sandbox/unix_local_runner.py

Lines changed: 144 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@
77

88
import argparse
99
import asyncio
10+
import io
1011
import sys
12+
import tempfile
1113
from pathlib import Path
1214

1315
from openai.types.responses import ResponseTextDeltaEvent
1416

1517
from agents import Runner
1618
from agents.run import RunConfig
17-
from agents.sandbox import SandboxAgent, SandboxRunConfig
19+
from agents.sandbox import Manifest, SandboxAgent, SandboxPathGrant, SandboxRunConfig
20+
from agents.sandbox.errors import WorkspaceArchiveWriteError
1821
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
1922

2023
if __package__ is None or __package__ == "":
@@ -29,9 +32,9 @@
2932
)
3033

3134

32-
async def main(model: str, question: str, stream: bool) -> None:
35+
def _build_manifest(external_dir: Path, scratch_dir: Path) -> Manifest:
3336
# The manifest is the file tree that will be materialized into the sandbox workspace.
34-
manifest = text_manifest(
37+
return text_manifest(
3538
{
3639
"account_brief.md": (
3740
"# Northwind Health\n\n"
@@ -57,54 +60,155 @@ async def main(model: str, question: str, stream: bool) -> None:
5760
"- Customer procurement requires final legal language by April 1.\n"
5861
),
5962
}
63+
).model_copy(
64+
update={
65+
"extra_path_grants": (
66+
SandboxPathGrant(
67+
path=str(external_dir),
68+
read_only=True,
69+
description="read-only external renewal packet notes",
70+
),
71+
SandboxPathGrant(
72+
path=str(scratch_dir),
73+
description="temporary renewal packet scratch files",
74+
),
75+
)
76+
},
77+
deep=True,
6078
)
6179

62-
# The sandbox agent sees the manifest as its workspace and uses one shared shell tool
63-
# to inspect the files before answering.
64-
agent = SandboxAgent(
65-
name="Renewal Packet Analyst",
66-
model=model,
67-
instructions=(
68-
"You review renewal packets for an account team. Inspect the packet before answering. "
69-
"Keep the response concise, business-focused, and cite the file names that support "
70-
"each conclusion. "
71-
"If a conclusion depends on a file, mention that file by name. Do not invent numbers "
72-
"or statuses that are not present in the workspace."
73-
),
74-
default_manifest=manifest,
75-
capabilities=[WorkspaceShellCapability()],
76-
)
77-
78-
# With Unix-local sandboxes, the runner creates and cleans up the temporary workspace for us.
79-
run_config = RunConfig(
80-
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
81-
workflow_name="Unix local sandbox review",
82-
)
8380

84-
if not stream:
85-
result = await Runner.run(agent, question, run_config=run_config)
86-
print(result.final_output)
87-
return
81+
async def _verify_extra_path_grants() -> None:
82+
with tempfile.TemporaryDirectory(prefix="agents-unix-local-extra-") as extra_root_text:
83+
extra_root = Path(extra_root_text)
84+
external_dir = extra_root / "external"
85+
scratch_dir = extra_root / "scratch"
86+
external_dir.mkdir()
87+
scratch_dir.mkdir()
88+
external_input = external_dir / "external_input.txt"
89+
read_only_output = external_dir / "blocked.txt"
90+
sdk_output = scratch_dir / "sdk_output.txt"
91+
exec_output = scratch_dir / "exec_output.txt"
92+
external_input.write_text("external grant input\n", encoding="utf-8")
93+
94+
client = UnixLocalSandboxClient()
95+
sandbox = await client.create(manifest=_build_manifest(external_dir, scratch_dir))
96+
try:
97+
async with sandbox:
98+
payload = await sandbox.read(external_input)
99+
try:
100+
await sandbox.write(read_only_output, io.BytesIO(b"should fail\n"))
101+
except WorkspaceArchiveWriteError:
102+
pass
103+
else:
104+
raise RuntimeError(
105+
"SDK write to read-only extra path grant unexpectedly worked."
106+
)
107+
await sandbox.write(sdk_output, io.BytesIO(b"sdk grant output\n"))
108+
exec_result = await sandbox.exec(
109+
"sh",
110+
"-c",
111+
'cat "$1"; printf "%s\\n" "exec grant output" > "$2"',
112+
"sh",
113+
external_input,
114+
exec_output,
115+
shell=False,
116+
)
117+
118+
if payload.read() != b"external grant input\n":
119+
raise RuntimeError(
120+
"SDK read from extra path grant returned unexpected content."
121+
)
122+
if sdk_output.read_text(encoding="utf-8") != "sdk grant output\n":
123+
raise RuntimeError("SDK write to extra path grant failed.")
124+
if exec_result.stdout != b"external grant input\n" or exec_result.exit_code != 0:
125+
raise RuntimeError("Shell read from extra path grant failed.")
126+
if exec_output.read_text(encoding="utf-8") != "exec grant output\n":
127+
raise RuntimeError("Shell write to extra path grant failed.")
128+
finally:
129+
await client.delete(sandbox)
130+
131+
print("extra_path_grants verification passed")
88132

89-
# The streaming path prints text deltas as they arrive so the example behaves like a demo.
90-
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
91-
saw_text_delta = False
92-
async for event in stream_result.stream_events():
93-
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
94-
if not saw_text_delta:
95-
print("assistant> ", end="", flush=True)
96-
saw_text_delta = True
97-
print(event.data.delta, end="", flush=True)
98133

99-
if saw_text_delta:
100-
print()
134+
async def main(model: str, question: str, stream: bool) -> None:
135+
with tempfile.TemporaryDirectory(prefix="agents-unix-local-extra-") as extra_root_text:
136+
extra_root = Path(extra_root_text)
137+
external_dir = extra_root / "external"
138+
scratch_dir = extra_root / "scratch"
139+
external_dir.mkdir()
140+
scratch_dir.mkdir()
141+
external_note = external_dir / "external_renewal_note.md"
142+
scratch_note = scratch_dir / "scratch_summary.md"
143+
external_note.write_text(
144+
"# External renewal note\n\n"
145+
"Finance approved discount authority up to 10 percent, but anything higher needs "
146+
"CFO approval before legal can finalize terms.\n",
147+
encoding="utf-8",
148+
)
149+
manifest = _build_manifest(external_dir, scratch_dir)
150+
151+
# The sandbox agent sees the manifest as its workspace and uses one shared shell tool
152+
# to inspect the files before answering.
153+
agent = SandboxAgent(
154+
name="Renewal Packet Analyst",
155+
model=model,
156+
instructions=(
157+
"You review renewal packets for an account team. Inspect the packet before "
158+
"answering. Keep the response concise, business-focused, and cite the file names "
159+
"that support each conclusion. If a conclusion depends on a file, mention that "
160+
"file by name. Do not invent numbers or statuses that are not present in the "
161+
"workspace. The manifest also grants read-only access to an external note at "
162+
f"`{external_note}` and read-write access to a scratch directory at "
163+
f"`{scratch_dir}`. Read the external note before answering, and write a brief "
164+
f"scratch note to `{scratch_note}`."
165+
),
166+
default_manifest=manifest,
167+
capabilities=[WorkspaceShellCapability()],
168+
)
169+
170+
# With Unix-local sandboxes, the runner creates and cleans up the temporary workspace for us.
171+
run_config = RunConfig(
172+
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
173+
workflow_name="Unix local sandbox review",
174+
tracing_disabled=True,
175+
)
176+
177+
if not stream:
178+
result = await Runner.run(agent, question, run_config=run_config)
179+
print(result.final_output)
180+
return
181+
182+
# The streaming path prints text deltas as they arrive so the example behaves like a demo.
183+
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
184+
saw_text_delta = False
185+
async for event in stream_result.stream_events():
186+
if event.type == "raw_response_event" and isinstance(
187+
event.data, ResponseTextDeltaEvent
188+
):
189+
if not saw_text_delta:
190+
print("assistant> ", end="", flush=True)
191+
saw_text_delta = True
192+
print(event.data.delta, end="", flush=True)
193+
194+
if saw_text_delta:
195+
print()
101196

102197

103198
if __name__ == "__main__":
104199
parser = argparse.ArgumentParser()
105200
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
106201
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
107202
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
203+
parser.add_argument(
204+
"--verify-extra-path-grants",
205+
action="store_true",
206+
default=False,
207+
help="Run a local extra_path_grants smoke test without calling a model.",
208+
)
108209
args = parser.parse_args()
109210

110-
asyncio.run(main(args.model, args.question, args.stream))
211+
if args.verify_extra_path_grants:
212+
asyncio.run(_verify_extra_path_grants())
213+
else:
214+
asyncio.run(main(args.model, args.question, args.stream))

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "openai-agents"
3-
version = "0.14.1"
3+
version = "0.14.2"
44
description = "OpenAI Agents SDK"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/agents/extensions/sandbox/blaxel/sandbox.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
resolve_pty_write_yield_time_ms,
5555
truncate_text_by_tokens,
5656
)
57+
from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript
5758
from ....sandbox.session.sandbox_client import BaseSandboxClient
5859
from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
5960
from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
@@ -345,6 +346,12 @@ async def shutdown(self) -> None:
345346
except Exception as e:
346347
logger.warning("sandbox delete failed during shutdown: %s", e)
347348

349+
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
350+
return await self._validate_remote_path_access(path, for_write=for_write)
351+
352+
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
353+
return (RESOLVE_WORKSPACE_PATH_HELPER,)
354+
348355
# -- file operations -----------------------------------------------------
349356

350357
async def mkdir(
@@ -357,7 +364,7 @@ async def mkdir(
357364
if user is not None:
358365
path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
359366
else:
360-
path = self.normalize_path(path)
367+
path = await self._validate_path_access(path, for_write=True)
361368
if path == Path("/"):
362369
return
363370
try:
@@ -372,9 +379,10 @@ async def mkdir(
372379
async def read(self, path: Path | str, *, user: str | User | None = None) -> io.IOBase:
373380
path = Path(path)
374381
if user is not None:
375-
await self._check_read_with_exec(path, user=user)
382+
workspace_path = await self._check_read_with_exec(path, user=user)
383+
else:
384+
workspace_path = await self._validate_path_access(path)
376385

377-
workspace_path = self.normalize_path(path)
378386
try:
379387
data: Any = await self._sandbox.fs.read_binary(str(workspace_path))
380388
if isinstance(data, str):
@@ -409,7 +417,7 @@ async def write(
409417
if not isinstance(payload, bytes | bytearray):
410418
raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
411419

412-
workspace_path = self.normalize_path(path)
420+
workspace_path = await self._validate_path_access(path, for_write=True)
413421
try:
414422
await self._sandbox.fs.write_binary(str(workspace_path), bytes(payload))
415423
except Exception as e:

src/agents/extensions/sandbox/cloudflare/sandbox.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,8 @@ def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
319319
def _current_runtime_helper_cache_key(self) -> object | None:
320320
return self.state.sandbox_id
321321

322-
async def _normalize_path_for_io(self, path: Path | str) -> Path:
323-
return await self._normalize_path_for_remote_io(path)
322+
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
323+
return await self._validate_remote_path_access(path, for_write=for_write)
324324

325325
async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
326326
"""Cloudflare sandboxes do not yet support exposed port resolution."""
@@ -345,7 +345,7 @@ async def mount_bucket(
345345
mount_path: Path | str,
346346
options: dict[str, object],
347347
) -> None:
348-
workspace_path = self.normalize_path(mount_path)
348+
workspace_path = await self._validate_path_access(mount_path, for_write=True)
349349
http = self._session()
350350
url = self._url("mount")
351351
payload = {
@@ -389,7 +389,7 @@ async def mount_bucket(
389389
) from e
390390

391391
async def unmount_bucket(self, mount_path: Path | str) -> None:
392-
workspace_path = self.normalize_path(mount_path)
392+
workspace_path = await self._validate_path_access(mount_path, for_write=True)
393393
http = self._session()
394394
url = self._url("unmount")
395395
payload = {"mountPath": str(workspace_path)}
@@ -969,7 +969,7 @@ async def read(self, path: Path | str, *, user: str | User | None = None) -> io.
969969
if user is not None:
970970
await self._check_read_with_exec(path, user=user)
971971

972-
workspace_path = await self._normalize_path_for_io(path)
972+
workspace_path = await self._validate_path_access(path)
973973
http = self._session()
974974
url_path = quote(str(workspace_path).lstrip("/"), safe="/")
975975
url = self._url(f"file/{url_path}")
@@ -1040,7 +1040,7 @@ async def write(
10401040
raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
10411041

10421042
payload_bytes = bytes(payload)
1043-
workspace_path = await self._normalize_path_for_io(path)
1043+
workspace_path = await self._validate_path_access(path, for_write=True)
10441044

10451045
http = self._session()
10461046
url_path = quote(str(workspace_path).lstrip("/"), safe="/")

0 commit comments

Comments
 (0)