Skip to content

Commit 5797a7d

Browse files
committed
feat(examples): add sandbox session with env whitelist, timeout and output caps
1 parent 73f448c commit 5797a7d

2 files changed

Lines changed: 226 additions & 0 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Sandbox execution of skill scripts via SDK workspace runtimes."""
7+
import os
8+
import shutil
9+
import sys
10+
from dataclasses import dataclass
11+
12+
from trpc_agent_sdk.code_executors import (WorkspacePutFileInfo, WorkspaceRunProgramSpec,
13+
WorkspaceStageOptions,
14+
create_container_workspace_runtime,
15+
create_local_workspace_runtime)
16+
17+
SKILL_WS_DIR = "skills/code-review"
18+
DIFF_WS_PATH = "work/inputs/changes.diff"
19+
_PYTHON3_DIR = os.path.dirname(shutil.which("python3") or sys.executable)
20+
SANDBOX_ENV = {"PATH": f"{_PYTHON3_DIR}:/usr/local/bin:/usr/bin:/bin",
21+
"HOME": "/tmp", "LANG": "C.UTF-8"}
22+
23+
RUNTIME_LOCAL = "local"
24+
RUNTIME_CONTAINER = "container"
25+
RUNTIME_CUBE = "cube"
26+
27+
28+
async def create_runtime(kind: str):
29+
"""Create a workspace runtime. Container is the production default;
30+
local is a development-only fallback; cube needs env credentials."""
31+
if kind == RUNTIME_LOCAL:
32+
return create_local_workspace_runtime()
33+
if kind == RUNTIME_CONTAINER:
34+
return create_container_workspace_runtime()
35+
if kind == RUNTIME_CUBE:
36+
from trpc_agent_sdk.code_executors.cube import (CubeClientConfig,
37+
CubeWorkspaceRuntimeConfig,
38+
create_cube_sandbox_client,
39+
create_cube_workspace_runtime)
40+
if not os.getenv("CUBE_API_KEY") and not os.getenv("E2B_API_KEY"):
41+
raise ValueError("cube runtime requires CUBE_API_KEY/E2B_API_KEY; "
42+
"see examples/skills_with_cube")
43+
cfg = CubeClientConfig(
44+
execute_timeout=float(os.getenv("CUBE_EXECUTE_TIMEOUT", "60")),
45+
idle_timeout=int(os.getenv("CUBE_IDLE_TIMEOUT", "600")),
46+
auto_recover=True)
47+
client = await create_cube_sandbox_client(cfg)
48+
return create_cube_workspace_runtime(sandbox_client=client,
49+
execute_timeout=cfg.execute_timeout,
50+
workspace_cfg=CubeWorkspaceRuntimeConfig())
51+
raise ValueError(f"unknown runtime {kind!r}; expected local|container|cube")
52+
53+
54+
@dataclass
55+
class SandboxRunOutcome:
56+
"""Result of one sandboxed script execution."""
57+
58+
script: str
59+
status: str # ok | failed | timeout | error
60+
exit_code: int
61+
duration_ms: int
62+
timed_out: bool
63+
stdout: str
64+
stderr: str
65+
error_type: str = ""
66+
truncated: bool = False
67+
68+
69+
class SandboxSession:
70+
"""One workspace: staged code-review skill + staged diff + script runs."""
71+
72+
def __init__(self, runtime, skill_root: str, timeout_sec: float = 60.0,
73+
max_output_bytes: int = 262144):
74+
self._runtime = runtime
75+
self._skill_root = skill_root
76+
self._timeout = timeout_sec
77+
self._max_output = max_output_bytes
78+
self._ws = None
79+
self._exec_id = ""
80+
81+
async def open(self, exec_id: str) -> None:
82+
self._exec_id = exec_id
83+
manager = self._runtime.manager(None)
84+
self._ws = await manager.create_workspace(exec_id, None)
85+
fs = self._runtime.fs(None)
86+
await fs.stage_directory(self._ws, self._skill_root, SKILL_WS_DIR,
87+
WorkspaceStageOptions(mode="copy"), None)
88+
89+
async def put_diff(self, diff_text: str) -> None:
90+
fs = self._runtime.fs(None)
91+
await fs.put_files(self._ws, [WorkspacePutFileInfo(
92+
path=DIFF_WS_PATH, content=diff_text.encode("utf-8"), mode=0o644)], None)
93+
94+
def _truncate(self, text: str):
95+
if len(text.encode("utf-8", errors="replace")) <= self._max_output:
96+
return text, False
97+
return text.encode("utf-8", errors="replace")[:self._max_output].decode(
98+
"utf-8", errors="replace"), True
99+
100+
async def run_script(self, script_name: str,
101+
args: tuple = (DIFF_WS_PATH,)) -> SandboxRunOutcome:
102+
"""Run one skill script under `env -i` (environment whitelist)."""
103+
env_args = [f"{k}={v}" for k, v in SANDBOX_ENV.items()]
104+
argv = ["-i", *env_args, "python3",
105+
f"{SKILL_WS_DIR}/scripts/{script_name}", *args]
106+
spec = WorkspaceRunProgramSpec(cmd="env", args=argv, env={}, cwd=".",
107+
timeout=self._timeout)
108+
try:
109+
ret = await self._runtime.runner(None).run_program(self._ws, spec, None)
110+
except Exception as ex: # noqa: BLE001 - sandbox failure must not crash the review
111+
return SandboxRunOutcome(script=script_name, status="error", exit_code=-1,
112+
duration_ms=0, timed_out=False, stdout="",
113+
stderr=str(ex)[:1024], error_type=type(ex).__name__)
114+
stdout, t1 = self._truncate(ret.stdout or "")
115+
stderr, t2 = self._truncate(ret.stderr or "")
116+
if ret.timed_out:
117+
status = "timeout"
118+
elif ret.exit_code == 0:
119+
status = "ok"
120+
else:
121+
status = "failed"
122+
return SandboxRunOutcome(script=script_name, status=status,
123+
exit_code=ret.exit_code,
124+
duration_ms=int(ret.duration * 1000),
125+
timed_out=ret.timed_out, stdout=stdout,
126+
stderr=stderr,
127+
error_type="timeout" if ret.timed_out else "",
128+
truncated=t1 or t2)
129+
130+
async def close(self) -> None:
131+
if self._ws is None:
132+
return
133+
try:
134+
await self._runtime.manager(None).cleanup(self._exec_id, None)
135+
except Exception: # noqa: BLE001 - best-effort cleanup
136+
pass
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Tests for SandboxSession using the local workspace runtime."""
7+
import os
8+
from pathlib import Path
9+
10+
from review.sandbox import DIFF_WS_PATH, SandboxSession, create_runtime # noqa: F401
11+
12+
EXAMPLE_ROOT = Path(__file__).resolve().parents[1]
13+
SKILL_ROOT = EXAMPLE_ROOT / "skills" / "code-review"
14+
FIXTURES = EXAMPLE_ROOT / "fixtures"
15+
16+
17+
def _make_tmp_skill(tmp_path, script_name, body):
18+
skill = tmp_path / "code-review"
19+
(skill / "scripts").mkdir(parents=True)
20+
(skill / "SKILL.md").write_text("---\nname: code-review\ndescription: t\n---\nbody\n")
21+
(skill / "scripts" / script_name).write_text(body)
22+
return skill
23+
24+
25+
async def _open_session(skill_root, **kw):
26+
runtime = await create_runtime("local")
27+
session = SandboxSession(runtime, str(skill_root), **kw)
28+
await session.open("cr_test_" + os.urandom(4).hex())
29+
return session
30+
31+
32+
async def test_run_parse_diff_in_sandbox():
33+
session = await _open_session(SKILL_ROOT)
34+
try:
35+
await session.put_diff((FIXTURES / "clean.diff").read_text())
36+
outcome = await session.run_script("parse_diff.py")
37+
assert outcome.status == "ok", outcome.stderr
38+
assert '"files_changed": 2' in outcome.stdout
39+
finally:
40+
await session.close()
41+
42+
43+
async def test_env_whitelist_blocks_host_env(monkeypatch, tmp_path):
44+
monkeypatch.setenv("CR_LEAKED_SECRET", "should-not-be-visible")
45+
skill = _make_tmp_skill(tmp_path, "print_env.py",
46+
"import json, os\nprint(json.dumps(dict(os.environ)))\n")
47+
session = await _open_session(skill)
48+
try:
49+
await session.put_diff("")
50+
outcome = await session.run_script("print_env.py", args=())
51+
assert outcome.status == "ok", outcome.stderr
52+
assert "CR_LEAKED_SECRET" not in outcome.stdout
53+
finally:
54+
await session.close()
55+
56+
57+
async def test_timeout_is_enforced(tmp_path):
58+
skill = _make_tmp_skill(tmp_path, "sleepy.py", "import time\ntime.sleep(30)\n")
59+
session = await _open_session(skill, timeout_sec=2.0)
60+
try:
61+
await session.put_diff("")
62+
outcome = await session.run_script("sleepy.py", args=())
63+
assert outcome.timed_out is True
64+
assert outcome.status == "timeout"
65+
finally:
66+
await session.close()
67+
68+
69+
async def test_output_size_cap(tmp_path):
70+
skill = _make_tmp_skill(tmp_path, "noisy.py", "print('x' * 1000000)\n")
71+
session = await _open_session(skill, max_output_bytes=1024)
72+
try:
73+
await session.put_diff("")
74+
outcome = await session.run_script("noisy.py", args=())
75+
assert len(outcome.stdout) <= 1024
76+
assert outcome.truncated is True
77+
finally:
78+
await session.close()
79+
80+
81+
async def test_failing_script_reports_failed(tmp_path):
82+
skill = _make_tmp_skill(tmp_path, "broken.py", "import sys\nsys.exit(3)\n")
83+
session = await _open_session(skill)
84+
try:
85+
await session.put_diff("")
86+
outcome = await session.run_script("broken.py", args=())
87+
assert outcome.status == "failed"
88+
assert outcome.exit_code == 3
89+
finally:
90+
await session.close()

0 commit comments

Comments
 (0)