-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Add Novita as a sandbox backend #3846
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,302 @@ | ||
| """ | ||
| Novita Sandbox implementation for PraisonAI. | ||
|
|
||
| Provides code execution in Novita cloud sandboxes via novita-sandbox. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| import os | ||
| import shlex | ||
| import time | ||
| import uuid | ||
| from typing import Any, Dict, List, Optional, Union | ||
|
|
||
| from praisonaiagents.sandbox import ( | ||
| SandboxConfig, | ||
| SandboxResult, | ||
| SandboxStatus, | ||
| ResourceLimits, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| _INSTALL_HINT = "pip install praisonai-sandbox[novita]" | ||
|
|
||
|
|
||
| class NovitaSandbox: | ||
| """Novita cloud sandbox for isolated code execution. | ||
|
|
||
| Example: | ||
| import os | ||
| from praisonai_sandbox import NovitaSandbox | ||
|
|
||
| os.environ["NOVITA_API_KEY"] = "your-api-key" | ||
| sandbox = NovitaSandbox() | ||
| result = await sandbox.execute("print('Hello, World!')") | ||
| print(result.stdout) # Hello, World! | ||
|
|
||
| Requires: | ||
| - pip install novita-sandbox | ||
| - NOVITA_API_KEY environment variable | ||
| """ | ||
|
|
||
| def __init__(self, config: Optional[SandboxConfig] = None): | ||
| """Initialize the Novita sandbox. | ||
|
|
||
| Args: | ||
| config: Optional sandbox configuration | ||
| """ | ||
| self.config = config or SandboxConfig(sandbox_type="novita") | ||
| self._sandbox = None | ||
| self._is_running = False | ||
|
|
||
| @property | ||
| def is_available(self) -> bool: | ||
| """Check if Novita is available.""" | ||
| try: | ||
| import importlib | ||
| importlib.import_module("novita_sandbox") | ||
| api_key = os.getenv("NOVITA_API_KEY") | ||
| return api_key is not None and api_key.strip() != "" | ||
| except ImportError: | ||
| return False | ||
|
|
||
| @property | ||
| def sandbox_type(self) -> str: | ||
| return "novita" | ||
|
|
||
| async def start(self) -> None: | ||
| """Start/initialize the sandbox environment.""" | ||
| if self._is_running: | ||
| return | ||
|
|
||
| if not self.is_available: | ||
| raise RuntimeError( | ||
| f"Novita is not available. Please install novita-sandbox and set NOVITA_API_KEY. {_INSTALL_HINT}" | ||
| ) | ||
|
|
||
| try: | ||
| from novita_sandbox.core import AsyncSandbox | ||
| except ImportError: | ||
| raise ImportError( | ||
| "novita-sandbox not installed. " | ||
| "Install with: pip install novita-sandbox" | ||
| ) | ||
|
|
||
| self._sandbox = await AsyncSandbox.create( | ||
| timeout=self.config.resource_limits.timeout_seconds, | ||
| ) | ||
| self._is_running = True | ||
| logger.info("Novita sandbox initialized") | ||
|
Comment on lines
+70
to
+92
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== locate novita.py =="
fd -a 'novita\.py$' . || true
echo "== file stats outline =="
file="$(fd 'novita\.py$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
wc -l "$file"
ast-grep outline "$file" --view expanded || true
echo "== relevant lines 1-130 =="
sed -n '1,130p' "$file" | nl -ba
fi
echo "== search for ConcurrentStart tests / locks / startup patterns =="
rg -n "concurrent|Concurrent|_running|AsyncSandbox\.create|asyncio\.Lock|test_|StartAsync|Novita.*Sandbox" -S .Repository: MervinPraison/PraisonAI Length of output: 1193 🏁 Script executed: #!/bin/bash
set -euo pipefail
file="$(fd 'novita\.py$' . | sed '2q;d')"
test_file="$(fd 'test_novita\.py$' . | sed '2q;d')"
echo "== novita.py 1-130 =="
sed -n '1,130p' "$file" | awk '{printf "%4d\t%s\n", NR, $0}'
echo "== tests/search relevant =="
rg -n "concurrent|Concurrent|_running|AsyncSandbox\.create|asyncio\.Lock|Mock|start\(" "$file" "$test_file" -S
echo "== stop/create usages async =="
sed -n '130,302p' "$file" | awk '{printf "%4d\t%s\n", NR+129, $0}'Repository: MervinPraison/PraisonAI Length of output: 6137 🏁 Script executed: #!/bin/bash
set -euo pipefail
file="$(fd 'novita\.py$' . | sed '2q;d')"
test_file="$(fd 'test_novita\.py$' . | sed '2q;d')"
echo "== novita.py full =="
python3 - <<'PY' "$file"
import sys
for i, line in enumerate(open(sys.argv[1], encoding="utf-8"), 1):
print(f"{i:4}\t{line}", end="")
PY
echo "== concurrent start probe =="
python3 - <<'PY'
import asyncio
class NovitaLike:
def __init__(self):
self._is_running = False
self._sandbox = None
self.create_count = 0
def _is_running_check(self):
return self._is_running
async def _set_running(self):
await asyncio.sleep(0.001)
self._sandbox = f"started:{id(asyncio.current_task())}"
self.create_count += 1
self._is_running = True
async def start_unprotected(self):
if self._is_running_check():
return
await self._set_running()
async def main():
for i in range(3):
sandbox = NovitaLike()
await asyncio.gather(sandbox.start_unprotected(), sandbox.start_unprotected())
print(f"attempt={i} create_count={sandbox.create_count} sandbox={sandbox._sandbox} running={sandbox._is Running_check()}")
asyncio.run(main())
PY
echo "== imports/locks in novita and tests =="
pgrep -a pgrep | head -n1 || true
python3 - <<'PY'
from pathlib import Path
for p in [Path("src/praisonai-sandbox/praisonai_sandbox/novita.py"), Path("src/praisonai-sandbox/tests/test_novita.py")]:
print(f"--- {p} ---")
text = p.read_text(encoding="utf-8")
for token in ["asyncio", "Lock", "create_task", "task_group", "concurrent", "start", "AsyncSandbox.create"]:
if token in text:
print(token)
PYRepository: MervinPraison/PraisonAI Length of output: 4419 🏁 Script executed: #!/bin/bash
set -euo pipefail
src="src/praisonai-sandbox/praisonai_sandbox/novita.py"
test="src/praisonai-sandbox/tests/test_novita.py"
for p in "$src" "$test"; do
echo "== full $p =="
python3 - "$p" <<'PY'
import sys
for i, line in enumerate(open(sys.argv[1], encoding="utf-8"), 1):
print(f"{i:4}\t{line}", end="")
PY
done
echo "== concurrent state race probe =="
python3 - <<'PY'
import asyncio
class NovitaLike:
def __init__(self):
self._is_running = False
self._sandbox = None
self.create_count = 0
async def _set_running(self):
await asyncio.sleep(0.001)
self._sandbox = f"started:{id(asyncio.current_task())}"
self.create_count += 1
self._is_running = True
async def start_unprotected(self):
if self._is_running:
return
await self._set_running()
async def main():
for attempt in range(1):
sandbox = NovitaLike()
await asyncio.gather(sandbox.start_unprotected(), sandbox.start_unprotected())
print(f"create_count={sandbox.create_count} sandbox={sandbox._sandbox} is_running={sandbox._is_running}")
asyncio.run(main())
PYRepository: MervinPraison/PraisonAI Length of output: 15263 Serialize concurrent startup. The 🧰 Tools🪛 Ruff (0.16.1)[warning] 83-86: Within an (B904) 🤖 Prompt for AI Agents |
||
|
|
||
| async def stop(self) -> None: | ||
| """Stop/cleanup the sandbox environment.""" | ||
| if not self._is_running: | ||
| return | ||
|
|
||
| if self._sandbox: | ||
| try: | ||
| await self._sandbox.kill() | ||
| except Exception as e: | ||
| logger.warning(f"Failed to kill Novita sandbox: {e}") | ||
| self._sandbox = None | ||
|
|
||
| self._is_running = False | ||
| logger.info("Novita sandbox stopped") | ||
|
|
||
| async def execute( | ||
| self, | ||
| code: str, | ||
| language: str = "python", | ||
| limits: Optional[ResourceLimits] = None, | ||
| env: Optional[Dict[str, str]] = None, | ||
| working_dir: Optional[str] = None, | ||
| ) -> SandboxResult: | ||
| """Execute code in the sandbox.""" | ||
| if not self._is_running: | ||
| await self.start() | ||
|
|
||
| limits = limits or self.config.resource_limits | ||
| execution_id = str(uuid.uuid4()) | ||
| started_at = time.time() | ||
|
|
||
| if language == "python": | ||
| command = f"python3 -c {shlex.quote(code)}" | ||
| elif language == "bash": | ||
| command = code | ||
| else: | ||
| command = f"python3 -c {shlex.quote(code)}" | ||
|
|
||
| return await self._run_command( | ||
| command, | ||
| limits=limits, | ||
| env=env, | ||
| working_dir=working_dir, | ||
| execution_id=execution_id, | ||
| started_at=started_at, | ||
| ) | ||
|
|
||
| async def run_command( | ||
| self, | ||
| command: Union[str, List[str]], | ||
| limits: Optional[ResourceLimits] = None, | ||
| env: Optional[Dict[str, str]] = None, | ||
| working_dir: Optional[str] = None, | ||
| ) -> SandboxResult: | ||
| """Run a shell command in the sandbox.""" | ||
| if not self._is_running: | ||
| await self.start() | ||
|
|
||
| if isinstance(command, list): | ||
| command = shlex.join(command) | ||
|
|
||
| limits = limits or self.config.resource_limits | ||
| return await self._run_command( | ||
| command, | ||
| limits=limits, | ||
| env=env, | ||
| working_dir=working_dir, | ||
| execution_id=str(uuid.uuid4()), | ||
| started_at=time.time(), | ||
| ) | ||
|
|
||
| async def _run_command( | ||
| self, | ||
| command: str, | ||
| *, | ||
| limits: ResourceLimits, | ||
| env: Optional[Dict[str, str]], | ||
| working_dir: Optional[str], | ||
| execution_id: str, | ||
| started_at: float, | ||
| ) -> SandboxResult: | ||
| try: | ||
| result = await self._sandbox.commands.run( | ||
| command, | ||
| envs=env, | ||
| cwd=working_dir, | ||
| timeout=limits.timeout_seconds, | ||
|
Comment on lines
+176
to
+180
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Apply
🤖 Prompt for AI Agents |
||
| ) | ||
| status = SandboxStatus.COMPLETED if result.exit_code == 0 else SandboxStatus.FAILED | ||
| return SandboxResult( | ||
| execution_id=execution_id, | ||
| status=status, | ||
| exit_code=result.exit_code, | ||
| stdout=result.stdout or "", | ||
| stderr=result.stderr or "", | ||
| error=result.error, | ||
| duration_seconds=time.time() - started_at, | ||
| started_at=started_at, | ||
| completed_at=time.time(), | ||
| ) | ||
| except Exception as exc: | ||
| error = str(exc) | ||
| status = SandboxStatus.TIMEOUT if "timeout" in error.lower() else SandboxStatus.FAILED | ||
| return SandboxResult( | ||
| execution_id=execution_id, | ||
| status=status, | ||
| error=error, | ||
| duration_seconds=time.time() - started_at, | ||
| started_at=started_at, | ||
| completed_at=time.time(), | ||
| ) | ||
|
|
||
| async def execute_file( | ||
| self, | ||
| file_path: str, | ||
| args: Optional[List[str]] = None, | ||
| limits: Optional[ResourceLimits] = None, | ||
| env: Optional[Dict[str, str]] = None, | ||
| ) -> SandboxResult: | ||
| """Execute a file in the sandbox.""" | ||
| try: | ||
| with open(file_path, "r") as f: | ||
| code = f.read() | ||
|
Comment on lines
+214
to
+216
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Locate file and inspect context:"
fd -a 'novita.py' . | sed 's#^\./##'
file=$(fd 'novita.py' . | head -n1)
if [ -n "${file:-}" ]; then
wc -l "$file"
ast-grep outline "$file" || true
echo "=== relevant lines 1-260 ==="
sed -n '1,260p' "$file" | nl -ba
fi
echo "=== searches for execute_file and related config/default params ==="
rg -n "def execute_file|execute_file\\(|file_path|allowlist|allowed|sandbox|SandboxConfig|working_dir|env\\)" .Repository: MervinPraison/PraisonAI Length of output: 810 🏁 Script executed: #!/bin/bash
set -euo pipefail
file=$(fd 'novita.py' . | head -n1)
echo "file=$file"
echo "=== relevant lines 1-280 ==="
sed -n '1,280p' "$file" | awk '{printf "%5d\t%s\n", NR, $0}'
echo "=== execute_file definition and callers/test snippets ==="
rg -n -C 6 "def execute_file|execute_file\\(|file_path|allowed|allowlist|working_dir" .Repository: MervinPraison/PraisonAI Length of output: 50380 🏁 Script executed: #!/bin/bash
set -euo pipefail
file=$(fd 'novita.py' . | head -n1)
test_file=$(fd 'test_novita.py' . | head -n1)
echo "=== novita.py execute_file section ==="
sed -n '180,270p' "$file" | awk '{printf "%5d\t%s\n", NR+179, $0}'
echo "=== novita.py init/defaults section ==="
sed -n '28,130p' "$file" | awk '{printf "%5d\t%s\n", NR+27, $0}'
echo "=== novita.py execute_file signature/imports relevant ==="
sed -n '1,40p' "$file" | awk '{printf "%5d\t%s\n", NR, $0}'
echo "=== test_novita.py execute_file calls ==="
if [ -n "${test_file:-}" ]; then
rg -n -C 8 "execute_file\\(|def test_|file_path|working_dir" "$test_file"
fi
echo "=== scoped policy/search in praisonai-sandbox ==="
sed -n '1,280p' "$file" | awk '{printf "%5d\t%s\n", NR, $0}' | rg -n "file_path|allowed|allowlist|working_dir|file path|sandbox|execute_file"Repository: MervinPraison/PraisonAI Length of output: 15552 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
import ast
from pathlib import Path
path = Path("src/praisonai-sandbox/praisonai_sandbox/novita.py")
tree = ast.parse(path.read_text())
for cls in tree.body:
if isinstance(cls, ast.ClassDef) and cls.name == "NovitaSandbox":
for fn in cls.body:
if isinstance(fn, ast.AsyncFunctionDef) and fn.name in {"__init__", "execute_file"}:
print(f"{fn.name}:")
print(f" signature={ast.dump(ast.Module(body=[fn], type_ignores=[]), include_attributes=False)}")
opens = []
for node in ast.walk(fn):
if isinstance(node, ast.With) and any(
isinstance(t.context_expr, ast.Name) and t.context_expr.id == "open"
for t in node.items
):
opens.append(node)
print(f" open_statements={len(opens)}")
for with_node in opens:
open_call = next(
node
for t in with_node.items
if isinstance(t.context_expr, ast.Call)
and isinstance(t.context_expr.func, ast.Name)
and t.context_expr.func.id == "open"
for node in [t]
)
call = open_call.context_expr
print(f" open_file_arg={ast.dump(call.args[0]) if call.args else None}")
print(f" open_mode_arg={ast.dump(call.args[1]) if len(call.args) > 1 else None}")
print(f" open_kwargs_arg={ast.dump(call.keywords[0].value) if call.keywords and call.keywords[0].arg == 'mode' else None}")
PY
echo "=== read-only path normalization probe using Python stdlib only ==="
python3 - <<'PY'
import os
print("os.getcwd:", os.getcwd())
print("os.path.abspath('../../secret.py')", os.path.abspath(os.path.join("/app/praisonai/nov", "../../secret.py")))
print("os.path.realpath", os.path.realpath(os.path.join("/app/praisonai/nov", "../../secret.py")))
print("commonprefix example", os.path.commonprefix(["/app/work/project", os.path.abspath(os.path.join("/app/praisonai/nov", "../../secret.py"))]))
PYRepository: MervinPraison/PraisonAI Length of output: 5354 Enforce the host file access policy.
🧰 Tools🪛 ast-grep (0.45.0)[warning] 214-214: File path is request-/variable-derived; validate and normalize to prevent path traversal. (open-filename-from-request) 🤖 Prompt for AI Agents |
||
| except OSError as exc: | ||
| return SandboxResult( | ||
| execution_id=str(uuid.uuid4()), | ||
| status=SandboxStatus.FAILED, | ||
| error=f"Could not read {file_path}: {exc}", | ||
| ) | ||
|
|
||
| language = "bash" if file_path.endswith((".sh", ".bash")) else "python" | ||
| if args: | ||
| remote_path = f"/tmp/{uuid.uuid4().hex}_{os.path.basename(file_path)}" | ||
| if not await self.write_file(remote_path, code): | ||
| return SandboxResult( | ||
| execution_id=str(uuid.uuid4()), | ||
| status=SandboxStatus.FAILED, | ||
| error=f"Could not upload {file_path} to sandbox", | ||
| ) | ||
| interpreter = "bash" if language == "bash" else "python3" | ||
| parts = [interpreter, remote_path] + list(args) | ||
| return await self.run_command(parts, limits=limits, env=env) | ||
|
|
||
| return await self.execute(code, language=language, limits=limits, env=env) | ||
|
|
||
| async def write_file( | ||
| self, | ||
| path: str, | ||
| content: Union[str, bytes], | ||
| ) -> bool: | ||
| """Write a file to the sandbox.""" | ||
| if not self._is_running: | ||
| await self.start() | ||
|
|
||
| try: | ||
| if isinstance(content, bytes): | ||
| content = content.decode("utf-8") | ||
| await self._sandbox.files.write(path, content) | ||
| return True | ||
| except Exception as e: | ||
| logger.error(f"Failed to write file {path}: {e}") | ||
| return False | ||
|
|
||
| async def read_file( | ||
| self, | ||
| path: str, | ||
| ) -> Optional[Union[str, bytes]]: | ||
| """Read a file from the sandbox.""" | ||
| if not self._is_running: | ||
| await self.start() | ||
|
|
||
| try: | ||
| content = await self._sandbox.files.read(path) | ||
| return content | ||
| except Exception as e: | ||
| logger.warning(f"Failed to read file {path}: {e}") | ||
| return None | ||
|
|
||
| async def list_files( | ||
| self, | ||
| path: str = "/", | ||
| ) -> List[str]: | ||
| """List files in a sandbox directory.""" | ||
| if not self._is_running: | ||
| await self.start() | ||
|
|
||
| try: | ||
| entries = await self._sandbox.files.list(path) | ||
| return [entry.path for entry in entries] | ||
| except Exception: | ||
| return [] | ||
|
|
||
| def get_status(self) -> Dict[str, Any]: | ||
| """Get sandbox status information.""" | ||
| return { | ||
| "available": self.is_available, | ||
| "type": self.sandbox_type, | ||
| "running": self._is_running, | ||
| "api_key_set": bool(os.getenv("NOVITA_API_KEY")), | ||
| } | ||
|
|
||
| async def cleanup(self) -> None: | ||
| """Clean up sandbox resources.""" | ||
| await self.stop() | ||
|
|
||
| async def reset(self) -> None: | ||
| """Reset sandbox to initial state.""" | ||
| await self.stop() | ||
| await self.start() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Registering
novitawithout a corresponding_EXTRA_HINTSentry means an unavailable backend recommends onlypip install praisonai-sandbox, which does not installnovita-sandboxand leaves the backend unavailable.Knowledge Base Used: praisonai-sandbox