cocoonstack-sandbox-openai 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: cocoonstack-sandbox-openai
3
+ Version: 0.1.0
4
+ Summary: OpenAI Agents SDK sandbox provider backed by cocoon microVMs
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: openai-agents>=0.17
9
+ Requires-Dist: cocoonstack-sandbox>=0.1
10
+
11
+ # cocoonstack-sandbox-openai
12
+
13
+ OpenAI Agents SDK sandbox provider backed by cocoon microVMs: implements
14
+ the SDK's `BaseSandboxClient`/`BaseSandboxSession` pair over the sync
15
+ `cocoonstack-sandbox` SDK, bridged with `asyncio.to_thread`.
16
+
17
+ ```python
18
+ from cocoonsandbox_openai import CocoonSandboxClient, CocoonSandboxClientOptions
19
+
20
+ options = CocoonSandboxClientOptions(addr="10.0.0.5:7777", api_token="...")
21
+ session = await CocoonSandboxClient().create(options=options)
22
+ ```
23
+
24
+ One session is one claimed sandbox; `delete` releases it, `resume`
25
+ reattaches from serialized state (id + token). Exec maps to streaming
26
+ `run`, workspace persist/hydrate to tar pull/push, exposed ports to local
27
+ port proxies. Requires Python >= 3.10 (the `openai-agents` floor).
28
+
29
+ Full reference: https://cocoonstack.github.io/sandbox/openai-adapter
@@ -0,0 +1,19 @@
1
+ # cocoonstack-sandbox-openai
2
+
3
+ OpenAI Agents SDK sandbox provider backed by cocoon microVMs: implements
4
+ the SDK's `BaseSandboxClient`/`BaseSandboxSession` pair over the sync
5
+ `cocoonstack-sandbox` SDK, bridged with `asyncio.to_thread`.
6
+
7
+ ```python
8
+ from cocoonsandbox_openai import CocoonSandboxClient, CocoonSandboxClientOptions
9
+
10
+ options = CocoonSandboxClientOptions(addr="10.0.0.5:7777", api_token="...")
11
+ session = await CocoonSandboxClient().create(options=options)
12
+ ```
13
+
14
+ One session is one claimed sandbox; `delete` releases it, `resume`
15
+ reattaches from serialized state (id + token). Exec maps to streaming
16
+ `run`, workspace persist/hydrate to tar pull/push, exposed ports to local
17
+ port proxies. Requires Python >= 3.10 (the `openai-agents` floor).
18
+
19
+ Full reference: https://cocoonstack.github.io/sandbox/openai-adapter
@@ -0,0 +1,23 @@
1
+ """OpenAI Agents SDK sandbox provider for cocoon microVMs.
2
+
3
+ from agents.sandbox import SandboxRunConfig # SDK side
4
+ from cocoonsandbox_openai import CocoonSandboxClient, CocoonSandboxClientOptions
5
+
6
+ client = CocoonSandboxClient()
7
+ session = await client.create(options=CocoonSandboxClientOptions(
8
+ addr="10.0.0.5:7777", api_token="...", template="ghcr.io/cocoonstack/sandbox/rt:24.04"))
9
+ """
10
+
11
+ from .adapter import (
12
+ CocoonSandboxClient,
13
+ CocoonSandboxClientOptions,
14
+ CocoonSandboxSession,
15
+ CocoonSandboxSessionState,
16
+ )
17
+
18
+ __all__ = [
19
+ "CocoonSandboxClient",
20
+ "CocoonSandboxClientOptions",
21
+ "CocoonSandboxSession",
22
+ "CocoonSandboxSessionState",
23
+ ]
@@ -0,0 +1,179 @@
1
+ """OpenAI Agents SDK sandbox provider backed by cocoon microVMs: implements
2
+ the SDK's BaseSandboxClient/BaseSandboxSession pair on top of the sync
3
+ cocoonsandbox SDK, bridged with asyncio.to_thread. One session is one
4
+ claimed sandbox; delete releases it, resume reattaches by id + token."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import io
10
+ import socket
11
+ import uuid
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from agents.sandbox.manifest import Manifest
16
+ from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
17
+ from agents.sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
18
+ from agents.sandbox.session.sandbox_session import SandboxSession
19
+ from agents.sandbox.session.sandbox_session_state import SandboxSessionState
20
+ from agents.sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
21
+ from agents.sandbox.types import ExecResult, ExposedPortEndpoint, User
22
+ from cocoonsandbox import APIError, Client, Sandbox, SilkdError
23
+
24
+
25
+ class CocoonSandboxClientOptions(BaseSandboxClientOptions):
26
+ """Connection settings for a sandboxd node (or cluster entry node)."""
27
+
28
+ type: Literal["cocoon"] = "cocoon"
29
+ addr: str = "127.0.0.1:7777"
30
+ api_token: str = ""
31
+ template: str = "rt:24.04"
32
+ net: str = ""
33
+ ttl_seconds: int = 0
34
+
35
+
36
+ class CocoonSandboxSessionState(SandboxSessionState):
37
+ """Everything needed to reattach: the claim's id, token, and owner."""
38
+
39
+ type: Literal["cocoon"] = "cocoon"
40
+ addr: str = ""
41
+ api_token: str = ""
42
+ sandbox_id: str = ""
43
+ sandbox_token: str = ""
44
+ owner: str = ""
45
+
46
+
47
+ class CocoonSandboxSession(BaseSandboxSession):
48
+ """One claimed microVM behind the Agents SDK session surface."""
49
+
50
+ state: CocoonSandboxSessionState
51
+
52
+ def __init__(self, *, state: CocoonSandboxSessionState) -> None:
53
+ self.state = state
54
+ self._proxies: list[socket.socket] = []
55
+
56
+ @classmethod
57
+ def from_state(cls, state: CocoonSandboxSessionState) -> CocoonSandboxSession:
58
+ return cls(state=state)
59
+
60
+ async def _prepare_backend_workspace(self) -> None:
61
+ sb = self._sandbox()
62
+ await asyncio.to_thread(sb.mkdir, str(self.state.manifest.root), True)
63
+
64
+ async def _exec_internal(self, *command: str | Path, timeout: float | None = None) -> ExecResult:
65
+ # Bound the blocking SDK call by the socket timeout too, so a
66
+ # wait_for cancellation is matched by the worker thread actually
67
+ # unblocking (recv wakes) instead of lingering.
68
+ sb = self._sandbox(timeout=timeout)
69
+ argv = [str(part) for part in command]
70
+ stdout, stderr = bytearray(), bytearray()
71
+
72
+ def run() -> int:
73
+ return sb.run(argv, on_stdout=stdout.extend, on_stderr=stderr.extend)
74
+
75
+ code = await asyncio.wait_for(asyncio.to_thread(run), timeout=timeout)
76
+ return ExecResult(stdout=bytes(stdout), stderr=bytes(stderr), exit_code=code)
77
+
78
+ async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase:
79
+ sb = self._sandbox()
80
+ try:
81
+ data = await asyncio.to_thread(sb.read_file, str(self._abs(path)))
82
+ except SilkdError as exc:
83
+ if exc.kind == "not_found":
84
+ raise FileNotFoundError(str(path)) from exc
85
+ raise
86
+ return io.BytesIO(data)
87
+
88
+ async def write(self, path: Path, data: io.IOBase, *, user: str | User | None = None) -> None:
89
+ sb = self._sandbox()
90
+ payload = data.read()
91
+ await asyncio.to_thread(sb.write_file, str(self._abs(path)), payload)
92
+
93
+ async def running(self) -> bool:
94
+ try:
95
+ await asyncio.to_thread(self._sandbox().stat, "/")
96
+ except (APIError, SilkdError, OSError):
97
+ return False
98
+ return True
99
+
100
+ async def persist_workspace(self) -> io.IOBase:
101
+ sb = self._sandbox()
102
+ tar = await asyncio.to_thread(sb.pull, str(self.state.manifest.root))
103
+ return io.BytesIO(tar)
104
+
105
+ async def hydrate_workspace(self, data: io.IOBase) -> None:
106
+ sb = self._sandbox()
107
+ await asyncio.to_thread(sb.push, str(self.state.manifest.root), data.read())
108
+
109
+ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
110
+ sb = self._sandbox()
111
+ listener = await asyncio.to_thread(sb.proxy_port, "127.0.0.1:0", port)
112
+ self._proxies.append(listener)
113
+ return ExposedPortEndpoint(host="127.0.0.1", port=listener.getsockname()[1], tls=False)
114
+
115
+ async def _shutdown_backend(self) -> None:
116
+ # The sandbox itself outlives shutdown (delete releases it); only the
117
+ # local port proxies belong to this process.
118
+ for listener in self._proxies:
119
+ listener.close()
120
+ self._proxies.clear()
121
+
122
+ def _sandbox(self, timeout: float | None = None) -> Sandbox:
123
+ s = self.state
124
+ client = Client(s.addr, api_token=s.api_token, timeout=timeout or 120.0)
125
+ return Sandbox(client=client, id=s.sandbox_id, token=s.sandbox_token, owner=s.owner or s.addr)
126
+
127
+ def _abs(self, path: Path | str) -> Path:
128
+ # The SDK hands paths as str or Path; a relative one roots at the
129
+ # manifest workspace.
130
+ path = Path(path)
131
+ if path.is_absolute():
132
+ return path
133
+ return Path(self.state.manifest.root) / path
134
+
135
+
136
+ class CocoonSandboxClient(BaseSandboxClient[CocoonSandboxClientOptions]):
137
+ """Claims a sandbox per session; delete releases, resume reattaches."""
138
+
139
+ async def create(
140
+ self,
141
+ *,
142
+ snapshot: SnapshotSpec | SnapshotBase | None = None,
143
+ manifest: Manifest | None = None,
144
+ options: CocoonSandboxClientOptions,
145
+ ) -> SandboxSession:
146
+ client = Client(options.addr, api_token=options.api_token)
147
+ sb = await asyncio.to_thread(
148
+ client.new, options.template, net=options.net, ttl_seconds=options.ttl_seconds
149
+ )
150
+ if manifest is None:
151
+ manifest = Manifest(root="/workspace")
152
+ session_id = uuid.uuid4()
153
+ state = CocoonSandboxSessionState(
154
+ session_id=session_id,
155
+ manifest=manifest,
156
+ snapshot=resolve_snapshot(snapshot, str(session_id)),
157
+ addr=options.addr,
158
+ api_token=options.api_token,
159
+ sandbox_id=sb.id,
160
+ sandbox_token=sb.token,
161
+ owner=sb.owner,
162
+ )
163
+ return self._wrap_session(CocoonSandboxSession.from_state(state))
164
+
165
+ async def delete(self, session: SandboxSession) -> SandboxSession:
166
+ inner = session._inner
167
+ if not isinstance(inner, CocoonSandboxSession):
168
+ raise TypeError("CocoonSandboxClient.delete expects a CocoonSandboxSession")
169
+ await inner._shutdown_backend() # close any local port proxies
170
+ await asyncio.to_thread(inner._sandbox().close) # 404-safe in the SDK
171
+ return session
172
+
173
+ async def resume(self, state: SandboxSessionState) -> SandboxSession:
174
+ if not isinstance(state, CocoonSandboxSessionState):
175
+ raise TypeError("CocoonSandboxClient.resume expects a CocoonSandboxSessionState")
176
+ return self._wrap_session(CocoonSandboxSession.from_state(state))
177
+
178
+ def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
179
+ return CocoonSandboxSessionState.model_validate(payload)
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: cocoonstack-sandbox-openai
3
+ Version: 0.1.0
4
+ Summary: OpenAI Agents SDK sandbox provider backed by cocoon microVMs
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: openai-agents>=0.17
9
+ Requires-Dist: cocoonstack-sandbox>=0.1
10
+
11
+ # cocoonstack-sandbox-openai
12
+
13
+ OpenAI Agents SDK sandbox provider backed by cocoon microVMs: implements
14
+ the SDK's `BaseSandboxClient`/`BaseSandboxSession` pair over the sync
15
+ `cocoonstack-sandbox` SDK, bridged with `asyncio.to_thread`.
16
+
17
+ ```python
18
+ from cocoonsandbox_openai import CocoonSandboxClient, CocoonSandboxClientOptions
19
+
20
+ options = CocoonSandboxClientOptions(addr="10.0.0.5:7777", api_token="...")
21
+ session = await CocoonSandboxClient().create(options=options)
22
+ ```
23
+
24
+ One session is one claimed sandbox; `delete` releases it, `resume`
25
+ reattaches from serialized state (id + token). Exec maps to streaming
26
+ `run`, workspace persist/hydrate to tar pull/push, exposed ports to local
27
+ port proxies. Requires Python >= 3.10 (the `openai-agents` floor).
28
+
29
+ Full reference: https://cocoonstack.github.io/sandbox/openai-adapter
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ cocoonsandbox_openai/__init__.py
4
+ cocoonsandbox_openai/adapter.py
5
+ cocoonstack_sandbox_openai.egg-info/PKG-INFO
6
+ cocoonstack_sandbox_openai.egg-info/SOURCES.txt
7
+ cocoonstack_sandbox_openai.egg-info/dependency_links.txt
8
+ cocoonstack_sandbox_openai.egg-info/requires.txt
9
+ cocoonstack_sandbox_openai.egg-info/top_level.txt
10
+ tests/test_adapter.py
@@ -0,0 +1,2 @@
1
+ openai-agents>=0.17
2
+ cocoonstack-sandbox>=0.1
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cocoonstack-sandbox-openai"
7
+ version = "0.1.0"
8
+ description = "OpenAI Agents SDK sandbox provider backed by cocoon microVMs"
9
+ requires-python = ">=3.10"
10
+ license = { text = "Apache-2.0" }
11
+ readme = "README.md"
12
+ dependencies = ["openai-agents>=0.17", "cocoonstack-sandbox>=0.1"]
13
+
14
+ [tool.setuptools.packages.find]
15
+ include = ["cocoonsandbox_openai*"]
16
+
17
+ [tool.ruff]
18
+ line-length = 120
19
+ target-version = "py310"
20
+
21
+ [tool.ruff.lint]
22
+ select = ["E", "F", "W", "I", "UP", "B", "SIM"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,136 @@
1
+ """Adapter unit tests: exercise the BaseSandboxSession hooks against an
2
+ in-process fake sandboxd (control plane over http.server, data plane over
3
+ the SDK's own conn). No real VM — this binds the adapter's mapping onto the
4
+ cocoonsandbox surface, not cocoon itself."""
5
+
6
+ import asyncio
7
+ import io
8
+ import json
9
+ import threading
10
+ from http.server import BaseHTTPRequestHandler, HTTPServer
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+ from cocoonsandbox import SilkdError
15
+
16
+ from cocoonsandbox_openai import CocoonSandboxClient, CocoonSandboxClientOptions, CocoonSandboxSessionState
17
+
18
+
19
+ class FakeNode(BaseHTTPRequestHandler):
20
+ def do_POST(self):
21
+ length = int(self.headers.get("Content-Length") or 0)
22
+ _ = self.rfile.read(length)
23
+ if self.path == "/v1/claim":
24
+ self._reply(200, {"id": "sb_1", "token": "tok", "owner_addr": self.headers["Host"]})
25
+ elif self.path.endswith("/release"):
26
+ self._reply(204, None)
27
+ else:
28
+ self._reply(404, {"error": "no route"})
29
+
30
+ def _reply(self, code, payload):
31
+ self.send_response(code)
32
+ if payload is not None:
33
+ raw = json.dumps(payload).encode()
34
+ self.send_header("Content-Type", "application/json")
35
+ self.send_header("Content-Length", str(len(raw)))
36
+ self.end_headers()
37
+ self.wfile.write(raw)
38
+ else:
39
+ self.end_headers()
40
+
41
+ def log_message(self, *args):
42
+ pass
43
+
44
+
45
+ @pytest.fixture
46
+ def node():
47
+ server = HTTPServer(("127.0.0.1", 0), FakeNode)
48
+ threading.Thread(target=server.serve_forever, daemon=True).start()
49
+ yield f"127.0.0.1:{server.server_port}"
50
+ server.shutdown()
51
+
52
+
53
+ def test_create_claims_and_state_round_trips(node):
54
+ async def go():
55
+ client = CocoonSandboxClient()
56
+ session = await client.create(options=CocoonSandboxClientOptions(addr=node, template="rt:24.04"))
57
+ inner = session._inner
58
+ assert inner.state.sandbox_id == "sb_1" and inner.state.sandbox_token == "tok"
59
+
60
+ # serialize → deserialize must reattach to the same claim.
61
+ payload = client.serialize_session_state(inner.state)
62
+ restored = client.deserialize_session_state(payload)
63
+ assert isinstance(restored, CocoonSandboxSessionState)
64
+ assert restored.sandbox_id == "sb_1" and restored.owner == node
65
+
66
+ await client.delete(session)
67
+
68
+ asyncio.run(go())
69
+
70
+
71
+ def test_exec_maps_stdio_and_exit(node, monkeypatch):
72
+
73
+ class FakeSandbox:
74
+ def __init__(self, **kw):
75
+ self.id = "sb_1"
76
+
77
+ def run(self, argv, on_stdout=None, on_stderr=None):
78
+ assert argv == ["echo", "hi"]
79
+ on_stdout(b"hi\n")
80
+ return 0
81
+
82
+ async def go():
83
+ client = CocoonSandboxClient()
84
+ session = await client.create(options=CocoonSandboxClientOptions(addr=node))
85
+ inner = session._inner
86
+ monkeypatch.setattr(inner, "_sandbox", lambda timeout=None: FakeSandbox())
87
+ result = await inner._exec_internal("echo", "hi")
88
+ assert result.exit_code == 0 and result.stdout == b"hi\n"
89
+ assert result.ok()
90
+
91
+ asyncio.run(go())
92
+
93
+
94
+ def test_read_missing_maps_to_filenotfound(node, monkeypatch):
95
+ class FakeSandbox:
96
+ def read_file(self, path):
97
+ raise SilkdError("not_found", path)
98
+
99
+ async def go():
100
+ client = CocoonSandboxClient()
101
+ session = await client.create(options=CocoonSandboxClientOptions(addr=node))
102
+ inner = session._inner
103
+ monkeypatch.setattr(inner, "_sandbox", lambda timeout=None: FakeSandbox())
104
+ with pytest.raises(FileNotFoundError):
105
+ await inner.read(Path("/nope"))
106
+
107
+ asyncio.run(go())
108
+
109
+
110
+ def test_write_and_persist_use_tree_verbs(node, monkeypatch):
111
+ calls = {}
112
+
113
+ class FakeSandbox:
114
+ def write_file(self, path, data):
115
+ calls["write"] = (path, data)
116
+
117
+ def pull(self, path):
118
+ calls["pull"] = path
119
+ return b"tar-bytes"
120
+
121
+ def push(self, dest, data):
122
+ calls["push"] = (dest, data)
123
+
124
+ async def go():
125
+ client = CocoonSandboxClient()
126
+ session = await client.create(options=CocoonSandboxClientOptions(addr=node))
127
+ inner = session._inner
128
+ monkeypatch.setattr(inner, "_sandbox", lambda timeout=None: FakeSandbox())
129
+ await inner.write(Path("/workspace/a.txt"), io.BytesIO(b"body"))
130
+ assert calls["write"] == ("/workspace/a.txt", b"body")
131
+ tar = await inner.persist_workspace()
132
+ assert tar.read() == b"tar-bytes" and calls["pull"] == "/workspace"
133
+ await inner.hydrate_workspace(io.BytesIO(b"tar-in"))
134
+ assert calls["push"] == ("/workspace", b"tar-in")
135
+
136
+ asyncio.run(go())