From ba53ee7e4eb4418fa465c754479a29c56ed14f72 Mon Sep 17 00:00:00 2001 From: Abhishek Anand Date: Mon, 2 Feb 2026 16:11:47 +0530 Subject: [PATCH 01/16] refactor: API-first positioning and Python client library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite README: library-first, code execution focus, no AI slop - Add versioned API endpoints (/v1/execute, /v1/sessions, /v1/browser) - Add deprecated wrappers for old endpoints with Deprecation header - Add /health endpoint - Create Python client library (coderunner package) - Add pyproject.toml for pip installability Breaking changes (deprecated, still work): - /execute -> /v1/execute - /v1/sessions/session -> /v1/sessions, /v1/sessions/{id} - /v1/browser/interactions/* -> /v1/browser/* πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 373 ++++++++++++++--------------------------- coderunner/__init__.py | 19 +++ coderunner/client.py | 164 ++++++++++++++++++ pyproject.toml | 43 +++++ server.py | 121 ++++++++++++- 5 files changed, 471 insertions(+), 249 deletions(-) create mode 100644 coderunner/__init__.py create mode 100644 coderunner/client.py create mode 100644 pyproject.toml diff --git a/README.md b/README.md index bc71303..9855df3 100644 --- a/README.md +++ b/README.md @@ -1,149 +1,153 @@ -
-[![Start](https://img.shields.io/github/stars/instavm/coderunner?color=yellow&style=flat&label=%E2%AD%90%20stars)](https://github.com/instavm/coderunner/stargazers) +[![Stars](https://img.shields.io/github/stars/instavm/coderunner?color=yellow&style=flat&label=%E2%AD%90%20stars)](https://github.com/instavm/coderunner/stargazers) [![License](http://img.shields.io/:license-Apache%202.0-green.svg?style=flat)](https://github.com/instavm/coderunner/blob/master/LICENSE)
-# CodeRunner: Run AI Generated Code Locally +# CodeRunner -CodeRunner is an MCP (Model Context Protocol) server that executes AI-generated code in a sandboxed environment on your Mac using Apple's native [containers](https://github.com/apple/container). +Sandboxed Python execution for Mac. Local files, local processing. -**Key use case:** Process your local files (videos, images, documents, data) with remote LLMs like Claude or ChatGPT without uploading your files to the cloud. The LLM generates code that runs locally on your machine to analyze, transform, or process your files. +CodeRunner runs Python code in an isolated container on your Mac using [Apple's native container technology](https://github.com/apple/container). Process your local files without uploading them anywhere. -## What CodeRunner Enables +- Execute Python in a persistent Jupyter kernel +- Pre-installed data science stack (pandas, numpy, matplotlib, etc.) +- Files stay on your machine +- Optional browser automation via Playwright -| Without CodeRunner | With CodeRunner | -| :--- | :--- | -| LLM writes code, you run it manually | LLM writes and executes code, returns results | -| Upload files to cloud for AI processing | Files stay on your machine, processed locally | -| Install tools and dependencies yourself | Tools available in sandbox, auto-installs others | -| Copy/paste scripts to run elsewhere | Code runs immediately, shows output/files | -| LLM analyzes text descriptions of files | LLM directly processes your actual files | -| Manage Python environments and packages | Pre-configured environment ready to use | +**Requirements:** macOS, Apple Silicon (M1+), Python 3.10+ -## Quick Start +## Install -**Prerequisites:** Mac with macOS and Apple Silicon (M1/M2/M3/M4), Python 3.10+ +### Homebrew (coming soon) +```bash +brew tap instavm/tap +brew install coderunner +``` +### Manual ```bash git clone https://github.com/instavm/coderunner.git cd coderunner -chmod +x install.sh ./install.sh ``` -MCP server will be available at: http://coderunner.local:8222/mcp +Server runs at: `http://coderunner.local:8222` -**Install required packages** (use virtualenv and note the python path): +## Usage + +### Python Library + +Install the client (server must be running locally): ```bash -pip install -r examples/requirements.txt +pip install coderunner ``` -## Integration Options +```python +from coderunner import CodeRunner + +# Connect to local server +cr = CodeRunner() # defaults to http://coderunner.local:8222 + +# Execute Python code +result = cr.execute(""" +import pandas as pd +df = pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]}) +print(df.describe()) +""") +print(result.stdout) + +# One-liner for quick scripts +from coderunner import execute +print(execute("2 + 2")) # 4 +``` -### Option 1: Claude Desktop Integration +### REST API +```bash +# Execute Python code +curl -X POST http://coderunner.local:8222/v1/execute \ + -H "Content-Type: application/json" \ + -d '{"code": "import pandas as pd; print(pd.__version__)"}' +``` -
-Configure Claude Desktop to use CodeRunner as an MCP server: - -![demo1](images/demo.png) - -![demo2](images/demo2.png) - -![demo4](images/demo4.png) - -1. **Copy the example configuration:** - ```bash - cd examples - cp claude_desktop/claude_desktop_config.example.json claude_desktop/claude_desktop_config.json - ``` - -2. **Edit the configuration file** and replace the placeholder paths: - - Replace `/path/to/your/python` with your actual Python path (e.g., `/usr/bin/python3` or `/opt/homebrew/bin/python3`) - - Replace `/path/to/coderunner` with the actual path to your cloned repository - - Example after editing: - ```json - { - "mcpServers": { - "coderunner": { - "command": "/opt/homebrew/bin/python3", - "args": ["/Users/yourname/coderunner/examples/claude_desktop/mcpproxy.py"] - } - } - } - ``` - -3. **Update Claude Desktop configuration:** - - Open Claude Desktop - - Go to Settings β†’ Developer - - Add the MCP server configuration - - Restart Claude Desktop - -4. **Start using CodeRunner in Claude:** - You can now ask Claude to execute code, and it will run safely in the sandbox! -
+Response: +```json +{"stdout": "2.0.3\n", "stderr": "", "execution_time": 0.12} +``` + +### API Endpoints -### Option 2: Claude Code CLI +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/v1/execute` | POST | Execute Python code | +| `/v1/sessions` | POST | Create session | +| `/v1/sessions/{id}` | GET/DELETE | Session management | +| `/v1/browser/navigate` | POST | Browser navigation | +| `/v1/browser/content` | POST | Extract page content | +| `/health` | GET | Health check |
-Use CodeRunner with Claude Code CLI for terminal-based AI assistance: +Deprecated endpoints (still work, will be removed in v1.0) -**Quick Start:** +| Old | New | +|-----|-----| +| `/execute` | `/v1/execute` | +| `/v1/sessions/session` | `/v1/sessions` | +| `/v1/browser/interactions/navigate` | `/v1/browser/navigate` | +| `/v1/browser/interactions/content` | `/v1/browser/content` | -```bash -# 1. Install and start CodeRunner (one-time setup) -git clone https://github.com/instavm/coderunner.git -cd coderunner -sudo ./install.sh +
-# 2. Install the Claude Code plugin -claude plugin marketplace add https://github.com/instavm/coderunner-plugin -claude plugin install instavm-coderunner +## MCP Server -# 3. Reconnect to MCP servers -/mcp +For AI tools that support the Model Context Protocol, connect to: +``` +http://coderunner.local:8222/mcp ``` -**Installation Steps:** - -1. Navigate to Plugin Marketplace: - - ![Navigate to Plugin Marketplace](images/gotoplugin.png) - -2. Add the InstaVM repository: - - ![Add InstaVM Repository](images/addrepo.png) - -3. Execute Python code with Claude Code: +### Available Tools +- `execute_python_code(command)` - Run Python code +- `navigate_and_get_all_visible_text(url)` - Web scraping +- `list_skills()` - List available skills +- `get_skill_info(skill_name)` - Get skill documentation +- `get_skill_file(skill_name, filename)` - Read skill files - ![Execute Python Code](images/runcode.png) +### Integration Examples -That's it! Claude Code now has access to all CodeRunner tools: -- **execute_python_code** - Run Python code in persistent Jupyter kernel -- **navigate_and_get_all_visible_text** - Web scraping with Playwright -- **list_skills** - List available skills (docx, xlsx, pptx, pdf, image processing, etc.) -- **get_skill_info** - Get documentation for specific skills -- **get_skill_file** - Read skill files and examples +
+Claude Desktop -**Learn more:** See the [plugin repository](https://github.com/instavm/coderunner-plugin) for detailed documentation. +Edit your Claude Desktop config: +```json +{ + "mcpServers": { + "coderunner": { + "command": "/path/to/python3", + "args": ["/path/to/coderunner/examples/claude_desktop/mcpproxy.py"] + } + } +} +``` +See `examples/claude_desktop/claude_desktop_config.example.json` for a complete example.
-### Option 3: OpenCode Configuration -
-Configure OpenCode to use CodeRunner as an MCP server: +Claude Code CLI -![OpenCode Example](images/opencode-example.png) +```bash +claude plugin marketplace add https://github.com/instavm/coderunner-plugin +claude plugin install instavm-coderunner +``` +
-Create or edit `~/.config/opencode/opencode.json`: +
+OpenCode +Edit `~/.config/opencode/opencode.json`: ```json { - "$schema": "https://opencode.ai/config.json", "mcp": { "coderunner": { "type": "remote", @@ -153,44 +157,14 @@ Create or edit `~/.config/opencode/opencode.json`: } } ``` - -After saving the configuration: -1. Restart OpenCode -2. CodeRunner tools will be available automatically -3. Start executing Python code with full access to the sandboxed environment -
-### Option 4: Python OpenAI Agents
-Use CodeRunner with OpenAI's Python agents library: - -![demo3](images/demo3.png) - -1. **Set your OpenAI API key:** - ```bash - export OPENAI_API_KEY="your-openai-api-key-here" - ``` - -2. **Run the client:** - ```bash - python examples/openai_agents/openai_client.py - ``` - -3. **Start coding:** - Enter prompts like "write python code to generate 100 prime numbers" and watch it execute safely in the sandbox! -
- -### Option 5: Gemini-CLI -[Gemini CLI](https://github.com/google-gemini/gemini-cli) is recently launched by Google. - -
-~/.gemini/settings.json +Gemini CLI +Edit `~/.gemini/settings.json`: ```json { - "theme": "Default", - "selectedAuthType": "oauth-personal", "mcpServers": { "coderunner": { "httpUrl": "http://coderunner.local:8222/mcp" @@ -198,147 +172,60 @@ After saving the configuration: } } ``` - -
-![gemini1](images/gemini1.png) - -![gemini2](images/gemini2.png) - - -### Option 6: Kiro by Amazon -[Kiro](https://kiro.dev/blog/introducing-kiro/) is recently launched by Amazon. -
-~/.kiro/settings/mcp.json +Amazon Kiro +Edit `~/.kiro/settings/mcp.json`: ```json { "mcpServers": { "coderunner": { - "command": "/path/to/venv/bin/python", - "args": [ - "/path/to/coderunner/examples/claude_desktop/mcpproxy.py" - ], - "disabled": false, - "autoApprove": [ - "execute_python_code" - ] + "command": "/path/to/python", + "args": ["/path/to/coderunner/examples/claude_desktop/mcpproxy.py"] } } } ``` - - -![kiro](images/kiro.png) -
- -### Option 7: Coderunner-UI (Offline AI Workspace) -[Coderunner-UI](https://github.com/instavm/coderunner-ui) is our own offline AI workspace tool designed for full privacy and local processing. -
-coderunner-ui - -![coderunner-ui](images/coderunnerui.jpg) - -
+OpenAI Agents -## Security - -Code runs in an isolated container with VM-level isolation. Your host system and files outside the sandbox remain protected. - -From [@apple/container](https://github.com/apple/container/blob/main/docs/technical-overview.md): ->Each container has the isolation properties of a full VM, using a minimal set of core utilities and dynamic libraries to reduce resource utilization and attack surface. - -## Skills System - -CodeRunner includes a built-in skills system that provides pre-packaged tools for common tasks. Skills are organized into two categories: - -### Built-in Public Skills - -The following skills are included in every CodeRunner installation: - -- **pdf-text-replace** - Replace text in fillable PDF forms -- **image-crop-rotate** - Crop and rotate images - -### Using Skills - -Skills are accessed through MCP tools: - -```python -# List all available skills -result = await list_skills() - -# Get documentation for a specific skill -info = await get_skill_info("pdf-text-replace") - -# Execute a skill's script -code = """ -import subprocess -subprocess.run([ - 'python', - '/app/uploads/skills/public/pdf-text-replace/scripts/replace_text_in_pdf.py', - '/app/uploads/input.pdf', - 'OLD TEXT', - 'NEW TEXT', - '/app/uploads/output.pdf' -]) -""" -result = await execute_python_code(code) +```bash +export OPENAI_API_KEY="your-key" +python examples/openai_agents/openai_client.py ``` + -### Adding Custom Skills - -Users can add their own skills to the `~/.coderunner/assets/skills/user/` directory: - -1. Create a directory for your skill (e.g., `my-custom-skill/`) -2. Add a `SKILL.md` file with documentation -3. Add your scripts in a `scripts/` subdirectory -4. Skills will be automatically discovered by the `list_skills()` tool +## Skills -**Skill Structure:** -``` -~/.coderunner/assets/skills/user/my-custom-skill/ -β”œβ”€β”€ SKILL.md # Documentation with usage examples -└── scripts/ # Your Python/bash scripts - └── process.py -``` +CodeRunner includes a skills system for common tasks. -### Example: Using the PDF Text Replace Skill +**Built-in skills:** +- `pdf-text-replace` - Replace text in PDF forms +- `image-crop-rotate` - Image manipulation -```bash -# Inside the container, execute: -python /app/uploads/skills/public/pdf-text-replace/scripts/replace_text_in_pdf.py \ - /app/uploads/tax_form.pdf \ - "John Doe" \ - "Jane Smith" \ - /app/uploads/tax_form_updated.pdf -``` +**Add custom skills:** Place them in `~/.coderunner/assets/skills/user/` -## Architecture +See [SKILLS-README.md](SKILLS-README.md) for details. -CodeRunner consists of: -- **Sandbox Container:** Isolated execution environment with Jupyter kernel -- **MCP Server:** Handles communication between AI models and the sandbox -- **Skills System:** Pre-packaged tools for common tasks (PDF manipulation, image processing, etc.) +## Pre-installed Libraries -## Examples +The sandbox includes: pandas, numpy, scipy, matplotlib, seaborn, pillow, pypdf, python-docx, openpyxl, beautifulsoup4, requests, httpx, and more. -The `examples/` directory contains: -- `openai-agents` - Example OpenAI agents integration -- `claude-desktop` - Example Claude Desktop integration +## Security -## Building Container Image Tutorial +Code runs in VM-level isolation using Apple containers. -https://github.com/apple/container/blob/main/docs/tutorial.md +From [apple/container docs](https://github.com/apple/container/blob/main/docs/technical-overview.md): +> Each container has the isolation properties of a full VM, using a minimal set of core utilities and dynamic libraries to reduce resource utilization and attack surface. ## Contributing -We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. +See [CONTRIBUTING.md](CONTRIBUTING.md). ## License -This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. +Apache 2.0 diff --git a/coderunner/__init__.py b/coderunner/__init__.py new file mode 100644 index 0000000..185f5f4 --- /dev/null +++ b/coderunner/__init__.py @@ -0,0 +1,19 @@ +""" +CodeRunner - Sandboxed Python execution for Mac. + +Usage: + from coderunner import CodeRunner, execute + + # Using the client + cr = CodeRunner() + result = cr.execute("print('hello')") + print(result.stdout) + + # One-liner + print(execute("2 + 2")) +""" + +from .client import CodeRunner, ExecutionResult, BrowserResult, execute + +__all__ = ["CodeRunner", "ExecutionResult", "BrowserResult", "execute"] +__version__ = "0.1.0" diff --git a/coderunner/client.py b/coderunner/client.py new file mode 100644 index 0000000..c0c61a0 --- /dev/null +++ b/coderunner/client.py @@ -0,0 +1,164 @@ +""" +CodeRunner Python Client + +Simple wrapper for the CodeRunner REST API. + +Usage: + from coderunner import CodeRunner + + cr = CodeRunner() # defaults to http://coderunner.local:8222 + result = cr.execute("print('hello')") + print(result.stdout) +""" + +import httpx +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ExecutionResult: + """Result of code execution.""" + stdout: str + stderr: str + execution_time: float + success: bool + + +@dataclass +class BrowserResult: + """Result of browser content extraction.""" + content: str + url: str + success: bool + error: Optional[str] = None + + +class CodeRunner: + """ + Python client for CodeRunner API. + + Args: + base_url: Server URL. Defaults to http://coderunner.local:8222 + timeout: Request timeout in seconds. Defaults to 300 (5 minutes). + + Example: + >>> cr = CodeRunner() + >>> result = cr.execute("print('hello')") + >>> print(result.stdout) + hello + """ + + def __init__( + self, + base_url: str = "http://coderunner.local:8222", + timeout: float = 300.0 + ): + self.base_url = base_url.rstrip("/") + self._client = httpx.Client(timeout=timeout) + + def execute(self, code: str) -> ExecutionResult: + """ + Execute Python code and return result. + + Args: + code: Python code to execute. + + Returns: + ExecutionResult with stdout, stderr, execution_time, and success. + + Example: + >>> result = cr.execute("print(2 + 2)") + >>> result.stdout + '4\\n' + """ + response = self._client.post( + f"{self.base_url}/v1/execute", + json={"code": code} + ) + response.raise_for_status() + data = response.json() + return ExecutionResult( + stdout=data.get("stdout", ""), + stderr=data.get("stderr", ""), + execution_time=data.get("execution_time", 0.0), + success=not data.get("stderr") + ) + + def browse(self, url: str) -> BrowserResult: + """ + Navigate to URL and extract text content. + + Args: + url: URL to navigate to. + + Returns: + BrowserResult with extracted content. + + Example: + >>> result = cr.browse("https://example.com") + >>> print(result.content[:50]) + """ + response = self._client.post( + f"{self.base_url}/v1/browser/content", + json={"url": url} + ) + response.raise_for_status() + data = response.json() + return BrowserResult( + content=data.get("readable_content", {}).get("content", ""), + url=url, + success=data.get("status") == "success", + error=data.get("error") + ) + + def health(self) -> bool: + """ + Check if server is healthy. + + Returns: + True if server is healthy, False otherwise. + """ + try: + response = self._client.get(f"{self.base_url}/health") + return response.status_code == 200 + except Exception: + return False + + def close(self): + """Close the HTTP client.""" + self._client.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +def execute(code: str, base_url: str = "http://coderunner.local:8222") -> str: + """ + Execute Python code and return stdout. + + Convenience function for one-off execution. + + Args: + code: Python code to execute. + base_url: Server URL. Defaults to http://coderunner.local:8222 + + Returns: + stdout from execution. + + Raises: + RuntimeError: If execution produces stderr. + + Example: + >>> from coderunner import execute + >>> execute("print(2 + 2)") + '4\\n' + """ + with CodeRunner(base_url) as cr: + result = cr.execute(code) + if result.stderr: + raise RuntimeError(result.stderr) + return result.stdout diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e2eac7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "coderunner" +version = "0.1.0" +description = "Python client for CodeRunner - sandboxed code execution on Mac" +readme = "README.md" +license = {text = "Apache-2.0"} +requires-python = ">=3.10" +authors = [ + {name = "InstaVM", email = "hello@instavm.io"} +] +keywords = ["sandbox", "code-execution", "jupyter", "mac", "apple-silicon"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: MacOS", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "httpx>=0.24.0", +] + +[project.urls] +Homepage = "https://github.com/instavm/coderunner" +Repository = "https://github.com/instavm/coderunner" +Issues = "https://github.com/instavm/coderunner/issues" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-asyncio>=0.21", +] + +[tool.setuptools.packages.find] +include = ["coderunner*"] diff --git a/server.py b/server.py index 828d2f9..03d952b 100644 --- a/server.py +++ b/server.py @@ -1019,10 +1019,119 @@ async def api_stop_session(request: Request): }) +# --- HEALTH CHECK ENDPOINT --- + +async def api_health(request: Request): + """ + Health check endpoint. + + Response (JSON): + {"status": "healthy", "version": "0.1.0"} + """ + return JSONResponse({ + "status": "healthy", + "version": "0.1.0" + }) + + +# --- SESSION ENDPOINTS WITH PATH PARAMETERS --- + +async def api_get_session_by_id(request: Request): + """ + Get session status by ID. + + Response (JSON): + {"session_id": "...", "status": "active"} + """ + session_id = request.path_params.get("session_id", "session") + if session_id in _session_store: + return JSONResponse({ + "session_id": session_id, + "status": _session_store[session_id].get("status", "active") + }) + return JSONResponse({ + "session_id": session_id, + "status": "active" # For local use, sessions are always "active" + }) + + +async def api_stop_session_by_id(request: Request): + """ + Stop a session by ID. + """ + session_id = request.path_params.get("session_id", "session") + if session_id in _session_store: + del _session_store[session_id] + return JSONResponse({"status": "stopped"}) + + +# --- DEPRECATED ENDPOINT WRAPPERS --- +# These wrap old endpoints and log deprecation warnings + +async def deprecated_execute(request: Request): + """Deprecated: Use /v1/execute instead.""" + logger.warning("DEPRECATED: /execute is deprecated, use /v1/execute instead") + response = await api_execute(request) + response.headers["Deprecation"] = "true" + response.headers["Link"] = '; rel="successor-version"' + return response + + +async def deprecated_session_post(request: Request): + """Deprecated: Use /v1/sessions instead.""" + logger.warning("DEPRECATED: /v1/sessions/session POST is deprecated, use /v1/sessions instead") + response = await api_start_session(request) + response.headers["Deprecation"] = "true" + return response + + +async def deprecated_session_get(request: Request): + """Deprecated: Use /v1/sessions/{id} instead.""" + logger.warning("DEPRECATED: /v1/sessions/session GET is deprecated, use /v1/sessions/{id} instead") + response = await api_get_session(request) + response.headers["Deprecation"] = "true" + return response + + +async def deprecated_session_delete(request: Request): + """Deprecated: Use /v1/sessions/{id} instead.""" + logger.warning("DEPRECATED: /v1/sessions/session DELETE is deprecated, use /v1/sessions/{id} instead") + response = await api_stop_session(request) + response.headers["Deprecation"] = "true" + return response + + +async def deprecated_browser_navigate(request: Request): + """Deprecated: Use /v1/browser/navigate instead.""" + logger.warning("DEPRECATED: /v1/browser/interactions/navigate is deprecated, use /v1/browser/navigate instead") + response = await api_browser_navigate(request) + response.headers["Deprecation"] = "true" + return response + + +async def deprecated_browser_content(request: Request): + """Deprecated: Use /v1/browser/content instead.""" + logger.warning("DEPRECATED: /v1/browser/interactions/content is deprecated, use /v1/browser/content instead") + response = await api_browser_extract_content(request) + response.headers["Deprecation"] = "true" + return response + + # Add routes to the Starlette app -app.add_route("/execute", api_execute, methods=["POST"]) -app.add_route("/v1/sessions/session", api_start_session, methods=["POST"]) -app.add_route("/v1/sessions/session", api_get_session, methods=["GET"]) -app.add_route("/v1/sessions/session", api_stop_session, methods=["DELETE"]) -app.add_route("/v1/browser/interactions/navigate", api_browser_navigate, methods=["POST"]) -app.add_route("/v1/browser/interactions/content", api_browser_extract_content, methods=["POST"]) \ No newline at end of file + +# New endpoints (primary) +app.add_route("/health", api_health, methods=["GET"]) +app.add_route("/v1/execute", api_execute, methods=["POST"]) +app.add_route("/v1/sessions", api_start_session, methods=["POST"]) +app.add_route("/v1/sessions/{session_id}", api_get_session_by_id, methods=["GET"]) +app.add_route("/v1/sessions/{session_id}", api_stop_session_by_id, methods=["DELETE"]) +app.add_route("/v1/browser/navigate", api_browser_navigate, methods=["POST"]) +app.add_route("/v1/browser/content", api_browser_extract_content, methods=["POST"]) + +# Deprecated endpoints (backward compatibility - will be removed in v1.0) +app.add_route("/execute", deprecated_execute, methods=["POST"]) +app.add_route("/v1/sessions/session", deprecated_session_post, methods=["POST"]) +app.add_route("/v1/sessions/session", deprecated_session_get, methods=["GET"]) +app.add_route("/v1/sessions/session", deprecated_session_delete, methods=["DELETE"]) +app.add_route("/v1/browser/interactions/navigate", deprecated_browser_navigate, methods=["POST"]) +app.add_route("/v1/browser/interactions/content", deprecated_browser_content, methods=["POST"]) \ No newline at end of file From b2177b13984fccd5927a67d37503bb68d5906008 Mon Sep 17 00:00:00 2001 From: Abhishek Anand Date: Mon, 2 Feb 2026 16:20:40 +0530 Subject: [PATCH 02/16] test: add unit tests and container best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests: - Add 25 unit tests for client library - Add parsing tests for skill frontmatter - All tests pass without requiring running server Container improvements: - Add SIGTERM/SIGINT handler for graceful kernel shutdown - Add 10-minute timeout to container system start in install.sh - Improve cleanup.sh with graceful stop (no aggressive pkill) - Simplify Homebrew install command (auto-tap syntax) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 3 +- cleanup.sh | 37 +++++- install.sh | 23 +++- server.py | 46 +++++++ tests/__init__.py | 1 + tests/conftest.py | 89 ++++++++++++++ tests/test_client.py | 279 ++++++++++++++++++++++++++++++++++++++++++ tests/test_parsing.py | 228 ++++++++++++++++++++++++++++++++++ 8 files changed, 698 insertions(+), 8 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_client.py create mode 100644 tests/test_parsing.py diff --git a/README.md b/README.md index 9855df3..ffd858b 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ CodeRunner runs Python code in an isolated container on your Mac using [Apple's ### Homebrew (coming soon) ```bash -brew tap instavm/tap -brew install coderunner +brew install instavm/cli/coderunner ``` ### Manual diff --git a/cleanup.sh b/cleanup.sh index 30ebb00..873ab86 100755 --- a/cleanup.sh +++ b/cleanup.sh @@ -1,7 +1,38 @@ -sudo pkill -f container +#!/bin/bash +# +# CodeRunner Cleanup Script +# Gracefully stops containers and cleans up resources +# +echo "CodeRunner Cleanup" +echo "==================" -container system start +# Step 1: Try graceful stop of coderunner container +echo "β†’ Stopping coderunner container gracefully..." +container stop coderunner 2>/dev/null && echo " Stopped coderunner" || echo " coderunner not running" +# Wait for graceful shutdown +sleep 2 -container rm buildkit \ No newline at end of file +# Step 2: Remove coderunner container if it still exists +echo "β†’ Removing coderunner container..." +container rm coderunner 2>/dev/null || true + +# Step 3: Stop the container system +echo "β†’ Stopping container system..." +container system stop 2>/dev/null || true + +# Step 4: Clean up buildkit if requested +if [ "$1" = "--full" ]; then + echo "β†’ Full cleanup: removing buildkit..." + container rm buildkit 2>/dev/null || true +fi + +echo "" +echo "βœ… Cleanup complete" +echo "" +echo "To restart CodeRunner:" +echo " ./install.sh" +echo "" +echo "For full cleanup (including buildkit):" +echo " ./cleanup.sh --full" diff --git a/install.sh b/install.sh index cdbc6ea..10b0d36 100755 --- a/install.sh +++ b/install.sh @@ -57,9 +57,26 @@ sleep 2 # Start the container system (this is blocking and will wait for kernel download if needed) echo "Starting the Sandbox Container system (this may take a few minutes if downloading kernel)..." -if ! container system start; then - echo "❌ Failed to start container system." - exit 1 +echo "Note: First run may take 5+ minutes to download the kernel image." + +# Use timeout to prevent indefinite blocking (10 minutes max) +if command -v timeout &> /dev/null; then + if ! timeout 600 container system start; then + if [ $? -eq 124 ]; then + echo "❌ Container system start timed out after 10 minutes." + echo "This usually means the kernel download is taking too long." + echo "Try running manually: container system start" + else + echo "❌ Failed to start container system." + fi + exit 1 + fi +else + # timeout command not available (older macOS), run without timeout + if ! container system start; then + echo "❌ Failed to start container system." + exit 1 + fi fi # Quick verification that system is ready diff --git a/server.py b/server.py index 03d952b..99ab9f6 100644 --- a/server.py +++ b/server.py @@ -5,6 +5,8 @@ import json import logging import os +import signal +import sys import zipfile import pathlib import time @@ -333,6 +335,50 @@ async def _health_check_loop(self): # Global kernel pool instance kernel_pool = KernelPool() +# --- GRACEFUL SHUTDOWN --- + +_shutdown_event = asyncio.Event() + + +async def graceful_shutdown(): + """Clean up kernels on shutdown.""" + logger.info("Initiating graceful shutdown...") + try: + # Stop health check task if running + if kernel_pool._health_check_task: + kernel_pool._health_check_task.cancel() + try: + await kernel_pool._health_check_task + except asyncio.CancelledError: + pass + + # Remove all kernels + for kernel_id in list(kernel_pool.kernels.keys()): + logger.info(f"Shutting down kernel: {kernel_id}") + await kernel_pool._remove_kernel(kernel_id) + + logger.info("Graceful shutdown complete") + except Exception as e: + logger.error(f"Error during graceful shutdown: {e}") + + +def handle_sigterm(signum, frame): + """Handle SIGTERM signal for graceful shutdown.""" + logger.info(f"Received signal {signum}, initiating shutdown...") + # Schedule shutdown in the event loop + try: + loop = asyncio.get_running_loop() + loop.create_task(graceful_shutdown()) + except RuntimeError: + # No running loop, we're probably not started yet + pass + # Allow uvicorn to handle the actual shutdown + sys.exit(0) + + +# Register signal handlers +signal.signal(signal.SIGTERM, handle_sigterm) +signal.signal(signal.SIGINT, handle_sigterm) # --- HELPER FUNCTION --- diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d90da9f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests for CodeRunner diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c82dcee --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,89 @@ +""" +Pytest configuration and fixtures for CodeRunner tests. +""" + +import pytest +from unittest.mock import Mock, patch +import httpx + + +@pytest.fixture +def mock_httpx_client(): + """Mock httpx.Client for testing without network calls.""" + with patch("httpx.Client") as mock_class: + mock_instance = Mock() + mock_class.return_value = mock_instance + yield mock_instance + + +@pytest.fixture +def execute_success_response(): + """Standard successful execution response.""" + return { + "stdout": "4\n", + "stderr": "", + "execution_time": 0.05 + } + + +@pytest.fixture +def execute_error_response(): + """Execution response with error.""" + return { + "stdout": "", + "stderr": "NameError: name 'undefined' is not defined", + "execution_time": 0.02 + } + + +@pytest.fixture +def browse_success_response(): + """Successful browser content response.""" + return { + "readable_content": { + "content": "Example Domain\nThis domain is for use in examples." + }, + "status": "success" + } + + +@pytest.fixture +def browse_error_response(): + """Browser error response.""" + return { + "status": "error", + "error": "Connection refused" + } + + +@pytest.fixture +def health_success_response(): + """Healthy server response.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": "healthy", "version": "0.1.0"} + return mock_response + + +@pytest.fixture +def skill_frontmatter_content(): + """Sample SKILL.md content with frontmatter.""" + return """--- +name: pdf-text-replace +description: Replace text in PDF forms +version: 1.0.0 +--- + +# PDF Text Replace + +This skill replaces text in PDF forms. +""" + + +@pytest.fixture +def skill_no_frontmatter_content(): + """Sample SKILL.md content without frontmatter.""" + return """# PDF Text Replace + +This skill replaces text in PDF forms. +""" diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..a626284 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,279 @@ +""" +Tests for the CodeRunner Python client library. + +These tests mock HTTP responses to avoid requiring a running server. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +import httpx + +from coderunner import CodeRunner, ExecutionResult, BrowserResult, execute + + +class TestCodeRunnerClient: + """Tests for the CodeRunner client class.""" + + def test_init_default_url(self): + """Client uses default URL when none provided.""" + with patch("httpx.Client"): + cr = CodeRunner() + assert cr.base_url == "http://coderunner.local:8222" + + def test_init_custom_url(self): + """Client uses custom URL when provided.""" + with patch("httpx.Client"): + cr = CodeRunner(base_url="http://localhost:9000") + assert cr.base_url == "http://localhost:9000" + + def test_init_strips_trailing_slash(self): + """Client strips trailing slash from URL.""" + with patch("httpx.Client"): + cr = CodeRunner(base_url="http://localhost:9000/") + assert cr.base_url == "http://localhost:9000" + + def test_execute_success(self, execute_success_response): + """Execute returns correct result on success.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = execute_success_response + mock_response.raise_for_status = MagicMock() + mock_client.post.return_value = mock_response + mock_client_class.return_value = mock_client + + cr = CodeRunner() + result = cr.execute("print(2 + 2)") + + assert isinstance(result, ExecutionResult) + assert result.stdout == "4\n" + assert result.stderr == "" + assert result.execution_time == 0.05 + assert result.success is True + mock_client.post.assert_called_once() + + def test_execute_with_error(self, execute_error_response): + """Execute returns error in stderr when code fails.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = execute_error_response + mock_response.raise_for_status = MagicMock() + mock_client.post.return_value = mock_response + mock_client_class.return_value = mock_client + + cr = CodeRunner() + result = cr.execute("print(undefined)") + + assert result.success is False + assert "NameError" in result.stderr + + def test_execute_calls_correct_endpoint(self): + """Execute calls /v1/execute endpoint.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = {"stdout": "", "stderr": "", "execution_time": 0} + mock_response.raise_for_status = MagicMock() + mock_client.post.return_value = mock_response + mock_client_class.return_value = mock_client + + cr = CodeRunner(base_url="http://test:8222") + cr.execute("pass") + + mock_client.post.assert_called_with( + "http://test:8222/v1/execute", + json={"code": "pass"} + ) + + def test_browse_success(self, browse_success_response): + """Browse returns content on success.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = browse_success_response + mock_response.raise_for_status = MagicMock() + mock_client.post.return_value = mock_response + mock_client_class.return_value = mock_client + + cr = CodeRunner() + result = cr.browse("https://example.com") + + assert isinstance(result, BrowserResult) + assert "Example Domain" in result.content + assert result.url == "https://example.com" + assert result.success is True + + def test_browse_error(self, browse_error_response): + """Browse returns error on failure.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = browse_error_response + mock_response.raise_for_status = MagicMock() + mock_client.post.return_value = mock_response + mock_client_class.return_value = mock_client + + cr = CodeRunner() + result = cr.browse("https://invalid.local") + + assert result.success is False + assert result.error == "Connection refused" + + def test_health_check_healthy(self): + """Health returns True when server is healthy.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client.get.return_value = mock_response + mock_client_class.return_value = mock_client + + cr = CodeRunner() + assert cr.health() is True + + def test_health_check_unhealthy(self): + """Health returns False when server is down.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_client.get.side_effect = httpx.ConnectError("Connection refused") + mock_client_class.return_value = mock_client + + cr = CodeRunner() + assert cr.health() is False + + def test_health_check_server_error(self): + """Health returns False on server error.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 500 + mock_client.get.return_value = mock_response + mock_client_class.return_value = mock_client + + cr = CodeRunner() + assert cr.health() is False + + def test_context_manager(self): + """Client works as context manager.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + with CodeRunner() as cr: + assert cr is not None + + mock_client.close.assert_called_once() + + def test_close_method(self): + """Close method closes underlying client.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + cr = CodeRunner() + cr.close() + + mock_client.close.assert_called_once() + + +class TestExecuteFunction: + """Tests for the execute() convenience function.""" + + def test_execute_function_returns_stdout(self): + """Execute function returns stdout on success.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = { + "stdout": "hello\n", + "stderr": "", + "execution_time": 0.01 + } + mock_response.raise_for_status = MagicMock() + mock_client.post.return_value = mock_response + mock_client_class.return_value = mock_client + + result = execute("print('hello')") + assert result == "hello\n" + + def test_execute_function_raises_on_error(self): + """Execute function raises RuntimeError on stderr.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = { + "stdout": "", + "stderr": "SyntaxError: invalid syntax", + "execution_time": 0.01 + } + mock_response.raise_for_status = MagicMock() + mock_client.post.return_value = mock_response + mock_client_class.return_value = mock_client + + with pytest.raises(RuntimeError) as exc_info: + execute("invalid python code") + + assert "SyntaxError" in str(exc_info.value) + + def test_execute_function_custom_url(self): + """Execute function accepts custom base URL.""" + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = { + "stdout": "ok", + "stderr": "", + "execution_time": 0.01 + } + mock_response.raise_for_status = MagicMock() + mock_client.post.return_value = mock_response + mock_client_class.return_value = mock_client + + execute("pass", base_url="http://custom:9000") + + mock_client.post.assert_called_with( + "http://custom:9000/v1/execute", + json={"code": "pass"} + ) + + +class TestDataClasses: + """Tests for data classes.""" + + def test_execution_result_fields(self): + """ExecutionResult has correct fields.""" + result = ExecutionResult( + stdout="output", + stderr="error", + execution_time=1.5, + success=False + ) + assert result.stdout == "output" + assert result.stderr == "error" + assert result.execution_time == 1.5 + assert result.success is False + + def test_browser_result_fields(self): + """BrowserResult has correct fields.""" + result = BrowserResult( + content="page content", + url="https://example.com", + success=True, + error=None + ) + assert result.content == "page content" + assert result.url == "https://example.com" + assert result.success is True + assert result.error is None + + def test_browser_result_with_error(self): + """BrowserResult can hold error.""" + result = BrowserResult( + content="", + url="https://invalid.local", + success=False, + error="Connection refused" + ) + assert result.success is False + assert result.error == "Connection refused" diff --git a/tests/test_parsing.py b/tests/test_parsing.py new file mode 100644 index 0000000..73d3e7f --- /dev/null +++ b/tests/test_parsing.py @@ -0,0 +1,228 @@ +""" +Tests for parsing functions in server.py. + +These tests focus on the skill frontmatter parsing logic. +""" + +import pytest +import asyncio +from unittest.mock import mock_open, patch, AsyncMock +import aiofiles + + +# We need to test the _parse_skill_frontmatter function from server.py +# Since it's a private function, we'll import it directly for testing + + +class TestSkillFrontmatterParsing: + """Tests for SKILL.md frontmatter parsing.""" + + @pytest.fixture + def valid_frontmatter(self): + """Valid SKILL.md with frontmatter.""" + return """--- +name: pdf-text-replace +description: Replace text in PDF forms +version: 1.0.0 +author: InstaVM +--- + +# PDF Text Replace + +This skill replaces text in PDF forms. +""" + + @pytest.fixture + def no_frontmatter(self): + """SKILL.md without frontmatter.""" + return """# PDF Text Replace + +This skill replaces text in PDF forms. +""" + + @pytest.fixture + def empty_frontmatter(self): + """SKILL.md with empty frontmatter.""" + return """--- +--- + +# Empty Skill +""" + + @pytest.fixture + def partial_frontmatter(self): + """SKILL.md with partial frontmatter (missing closing).""" + return """--- +name: broken-skill + +# This frontmatter is never closed +""" + + @pytest.mark.asyncio + async def test_parse_valid_frontmatter(self, valid_frontmatter, tmp_path): + """Parses valid frontmatter correctly.""" + # Import here to avoid import issues + import sys + sys.path.insert(0, str(tmp_path.parent.parent)) + + skill_file = tmp_path / "SKILL.md" + skill_file.write_text(valid_frontmatter) + + # Inline implementation of parsing logic for testing + async def parse_frontmatter(file_path): + async with aiofiles.open(file_path, mode='r') as f: + content = await f.read() + frontmatter = [] + in_frontmatter = False + for line in content.splitlines(): + if line.strip() == '---': + if in_frontmatter: + break + else: + in_frontmatter = True + continue + if in_frontmatter: + frontmatter.append(line) + + metadata = {} + for line in frontmatter: + if ':' in line: + key, value = line.split(':', 1) + metadata[key.strip()] = value.strip() + return metadata + + result = await parse_frontmatter(skill_file) + + assert result["name"] == "pdf-text-replace" + assert result["description"] == "Replace text in PDF forms" + assert result["version"] == "1.0.0" + assert result["author"] == "InstaVM" + + @pytest.mark.asyncio + async def test_parse_no_frontmatter(self, no_frontmatter, tmp_path): + """Returns empty dict when no frontmatter.""" + skill_file = tmp_path / "SKILL.md" + skill_file.write_text(no_frontmatter) + + async def parse_frontmatter(file_path): + async with aiofiles.open(file_path, mode='r') as f: + content = await f.read() + frontmatter = [] + in_frontmatter = False + for line in content.splitlines(): + if line.strip() == '---': + if in_frontmatter: + break + else: + in_frontmatter = True + continue + if in_frontmatter: + frontmatter.append(line) + + metadata = {} + for line in frontmatter: + if ':' in line: + key, value = line.split(':', 1) + metadata[key.strip()] = value.strip() + return metadata + + result = await parse_frontmatter(skill_file) + assert result == {} + + @pytest.mark.asyncio + async def test_parse_empty_frontmatter(self, empty_frontmatter, tmp_path): + """Returns empty dict for empty frontmatter.""" + skill_file = tmp_path / "SKILL.md" + skill_file.write_text(empty_frontmatter) + + async def parse_frontmatter(file_path): + async with aiofiles.open(file_path, mode='r') as f: + content = await f.read() + frontmatter = [] + in_frontmatter = False + for line in content.splitlines(): + if line.strip() == '---': + if in_frontmatter: + break + else: + in_frontmatter = True + continue + if in_frontmatter: + frontmatter.append(line) + + metadata = {} + for line in frontmatter: + if ':' in line: + key, value = line.split(':', 1) + metadata[key.strip()] = value.strip() + return metadata + + result = await parse_frontmatter(skill_file) + assert result == {} + + @pytest.mark.asyncio + async def test_parse_handles_colons_in_value(self, tmp_path): + """Handles colons in values correctly.""" + content = """--- +name: test-skill +url: https://example.com:8080/path +--- +""" + skill_file = tmp_path / "SKILL.md" + skill_file.write_text(content) + + async def parse_frontmatter(file_path): + async with aiofiles.open(file_path, mode='r') as f: + content = await f.read() + frontmatter = [] + in_frontmatter = False + for line in content.splitlines(): + if line.strip() == '---': + if in_frontmatter: + break + else: + in_frontmatter = True + continue + if in_frontmatter: + frontmatter.append(line) + + metadata = {} + for line in frontmatter: + if ':' in line: + key, value = line.split(':', 1) + metadata[key.strip()] = value.strip() + return metadata + + result = await parse_frontmatter(skill_file) + assert result["url"] == "https://example.com:8080/path" + + +class TestExecutionResultParsing: + """Tests for parsing execution results.""" + + def test_empty_stdout_is_empty_string(self): + """Empty stdout should be empty string, not None.""" + from coderunner import ExecutionResult + + result = ExecutionResult( + stdout="", + stderr="", + execution_time=0.0, + success=True + ) + assert result.stdout == "" + assert isinstance(result.stdout, str) + + def test_multiline_stdout(self): + """Handles multiline stdout correctly.""" + from coderunner import ExecutionResult + + multiline = "line1\nline2\nline3\n" + result = ExecutionResult( + stdout=multiline, + stderr="", + execution_time=0.1, + success=True + ) + assert result.stdout.count("\n") == 3 + assert "line2" in result.stdout From 87c161cefada5295c4592a721a91992f12760cbc Mon Sep 17 00:00:00 2001 From: Abhishek Anand Date: Mon, 2 Feb 2026 23:57:42 +0530 Subject: [PATCH 03/16] fix: graceful shutdown, refactor tests, and update install instructions --- README.md | 5 +- server.py | 26 +++------- tests/test_parsing.py | 107 +++++------------------------------------- 3 files changed, 21 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index ffd858b..39a78ad 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ brew install instavm/cli/coderunner ```bash git clone https://github.com/instavm/coderunner.git cd coderunner -./install.sh +chmod +x install.sh +sudo ./install.sh ``` Server runs at: `http://coderunner.local:8222` @@ -39,7 +40,7 @@ Server runs at: `http://coderunner.local:8222` Install the client (server must be running locally): ```bash -pip install coderunner +pip install git+https://github.com/instavm/coderunner.git ``` ```python diff --git a/server.py b/server.py index 99ab9f6..915bada 100644 --- a/server.py +++ b/server.py @@ -335,6 +335,7 @@ async def _health_check_loop(self): # Global kernel pool instance kernel_pool = KernelPool() + # --- GRACEFUL SHUTDOWN --- _shutdown_event = asyncio.Event() @@ -362,25 +363,6 @@ async def graceful_shutdown(): logger.error(f"Error during graceful shutdown: {e}") -def handle_sigterm(signum, frame): - """Handle SIGTERM signal for graceful shutdown.""" - logger.info(f"Received signal {signum}, initiating shutdown...") - # Schedule shutdown in the event loop - try: - loop = asyncio.get_running_loop() - loop.create_task(graceful_shutdown()) - except RuntimeError: - # No running loop, we're probably not started yet - pass - # Allow uvicorn to handle the actual shutdown - sys.exit(0) - - -# Register signal handlers -signal.signal(signal.SIGTERM, handle_sigterm) -signal.signal(signal.SIGINT, handle_sigterm) - - # --- HELPER FUNCTION --- def create_jupyter_request(code: str) -> tuple[str, str]: """ @@ -811,6 +793,12 @@ async def report_progress(self, progress: int, message: str): # Use the streamable_http_app as it's the modern standard app = mcp.streamable_http_app() + +@app.on_event("shutdown") +async def on_shutdown(): + """Handle application shutdown.""" + await graceful_shutdown() + # Add custom REST API endpoints compatible with instavm SDK client async def api_execute(request: Request): """ diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 73d3e7f..e64b2ea 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -61,37 +61,12 @@ def partial_frontmatter(self): @pytest.mark.asyncio async def test_parse_valid_frontmatter(self, valid_frontmatter, tmp_path): """Parses valid frontmatter correctly.""" - # Import here to avoid import issues - import sys - sys.path.insert(0, str(tmp_path.parent.parent)) + from server import _parse_skill_frontmatter skill_file = tmp_path / "SKILL.md" skill_file.write_text(valid_frontmatter) - # Inline implementation of parsing logic for testing - async def parse_frontmatter(file_path): - async with aiofiles.open(file_path, mode='r') as f: - content = await f.read() - frontmatter = [] - in_frontmatter = False - for line in content.splitlines(): - if line.strip() == '---': - if in_frontmatter: - break - else: - in_frontmatter = True - continue - if in_frontmatter: - frontmatter.append(line) - - metadata = {} - for line in frontmatter: - if ':' in line: - key, value = line.split(':', 1) - metadata[key.strip()] = value.strip() - return metadata - - result = await parse_frontmatter(skill_file) + result = await _parse_skill_frontmatter(skill_file) assert result["name"] == "pdf-text-replace" assert result["description"] == "Replace text in PDF forms" @@ -101,68 +76,30 @@ async def parse_frontmatter(file_path): @pytest.mark.asyncio async def test_parse_no_frontmatter(self, no_frontmatter, tmp_path): """Returns empty dict when no frontmatter.""" + from server import _parse_skill_frontmatter + skill_file = tmp_path / "SKILL.md" skill_file.write_text(no_frontmatter) - async def parse_frontmatter(file_path): - async with aiofiles.open(file_path, mode='r') as f: - content = await f.read() - frontmatter = [] - in_frontmatter = False - for line in content.splitlines(): - if line.strip() == '---': - if in_frontmatter: - break - else: - in_frontmatter = True - continue - if in_frontmatter: - frontmatter.append(line) - - metadata = {} - for line in frontmatter: - if ':' in line: - key, value = line.split(':', 1) - metadata[key.strip()] = value.strip() - return metadata - - result = await parse_frontmatter(skill_file) + result = await _parse_skill_frontmatter(skill_file) assert result == {} @pytest.mark.asyncio async def test_parse_empty_frontmatter(self, empty_frontmatter, tmp_path): """Returns empty dict for empty frontmatter.""" + from server import _parse_skill_frontmatter + skill_file = tmp_path / "SKILL.md" skill_file.write_text(empty_frontmatter) - async def parse_frontmatter(file_path): - async with aiofiles.open(file_path, mode='r') as f: - content = await f.read() - frontmatter = [] - in_frontmatter = False - for line in content.splitlines(): - if line.strip() == '---': - if in_frontmatter: - break - else: - in_frontmatter = True - continue - if in_frontmatter: - frontmatter.append(line) - - metadata = {} - for line in frontmatter: - if ':' in line: - key, value = line.split(':', 1) - metadata[key.strip()] = value.strip() - return metadata - - result = await parse_frontmatter(skill_file) + result = await _parse_skill_frontmatter(skill_file) assert result == {} @pytest.mark.asyncio async def test_parse_handles_colons_in_value(self, tmp_path): """Handles colons in values correctly.""" + from server import _parse_skill_frontmatter + content = """--- name: test-skill url: https://example.com:8080/path @@ -171,29 +108,7 @@ async def test_parse_handles_colons_in_value(self, tmp_path): skill_file = tmp_path / "SKILL.md" skill_file.write_text(content) - async def parse_frontmatter(file_path): - async with aiofiles.open(file_path, mode='r') as f: - content = await f.read() - frontmatter = [] - in_frontmatter = False - for line in content.splitlines(): - if line.strip() == '---': - if in_frontmatter: - break - else: - in_frontmatter = True - continue - if in_frontmatter: - frontmatter.append(line) - - metadata = {} - for line in frontmatter: - if ':' in line: - key, value = line.split(':', 1) - metadata[key.strip()] = value.strip() - return metadata - - result = await parse_frontmatter(skill_file) + result = await _parse_skill_frontmatter(skill_file) assert result["url"] == "https://example.com:8080/path" From ae745a39a472702e987551d6489f2dc0209f4667 Mon Sep 17 00:00:00 2001 From: Abhishek Anand Date: Tue, 3 Feb 2026 00:17:06 +0530 Subject: [PATCH 04/16] Fix PR review issues: remove unused event, use specific exception --- coderunner/client.py | 2 +- server.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/coderunner/client.py b/coderunner/client.py index c0c61a0..a64d179 100644 --- a/coderunner/client.py +++ b/coderunner/client.py @@ -122,7 +122,7 @@ def health(self) -> bool: try: response = self._client.get(f"{self.base_url}/health") return response.status_code == 200 - except Exception: + except httpx.RequestError: return False def close(self): diff --git a/server.py b/server.py index 915bada..b4366e5 100644 --- a/server.py +++ b/server.py @@ -338,8 +338,6 @@ async def _health_check_loop(self): # --- GRACEFUL SHUTDOWN --- -_shutdown_event = asyncio.Event() - async def graceful_shutdown(): """Clean up kernels on shutdown.""" From 5ae7afe82eacdbc3a92815ccf2a9931827e676f6 Mon Sep 17 00:00:00 2001 From: mkagenius Date: Sat, 7 Feb 2026 03:00:51 +0530 Subject: [PATCH 05/16] Add Apple container Claude CLI runner --- Dockerfile.claude | 14 +++++++++ README.md | 14 +++++++++ coderunner/cli.py | 80 +++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 ++ 4 files changed, 111 insertions(+) create mode 100644 Dockerfile.claude create mode 100644 coderunner/cli.py diff --git a/Dockerfile.claude b/Dockerfile.claude new file mode 100644 index 0000000..fb11b14 --- /dev/null +++ b/Dockerfile.claude @@ -0,0 +1,14 @@ +FROM node:22-bookworm-slim + +WORKDIR /workspace + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + openssh-client \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g @anthropic-ai/claude-code \ + && npm cache clean --force + +ENTRYPOINT ["claude"] diff --git a/README.md b/README.md index 39a78ad..a89db98 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,20 @@ Server runs at: `http://coderunner.local:8222` ## Usage +### Claude Code CLI (Apple container) + +Build the minimal Claude image and run it via the `coderunner` CLI: + +```bash +container build --tag coderunner-claude --file Dockerfile.claude . +coderunner claude --branch +``` + +Notes: +- The command passes extra args through to `claude` (so `--branch` goes to Claude Code). +- Set `ANTHROPIC_API_KEY` in your environment for non-interactive usage. +- Override the image with `CODERUNNER_IMAGE` or `--image` if you tag it differently. + ### Python Library Install the client (server must be running locally): diff --git a/coderunner/cli.py b/coderunner/cli.py new file mode 100644 index 0000000..615a46f --- /dev/null +++ b/coderunner/cli.py @@ -0,0 +1,80 @@ +import argparse +import os +import shutil +import subprocess +import sys + + +DEFAULT_IMAGE = os.environ.get("CODERUNNER_IMAGE", "coderunner-claude") +DEFAULT_CONTAINER = os.environ.get("CODERUNNER_CLAUDE_CONTAINER", "coderunner-claude") + + +def _container_available() -> bool: + return shutil.which("container") is not None + + +def _run_claude(args: argparse.Namespace, passthrough: list[str]) -> int: + if not _container_available(): + print("Error: Apple 'container' CLI not found. Run ./install.sh to install it.", file=sys.stderr) + return 1 + + workdir = os.path.abspath(args.workdir) + cmd = [ + "container", + "run", + "--rm", + "-i", + "-t", + "--entrypoint", + "claude", + "--name", + args.container, + "-e", + "ANTHROPIC_API_KEY", + ] + + if not args.no_mount: + cmd += ["--volume", f"{workdir}:/workspace", "--workdir", "/workspace"] + + cmd.append(args.image) + cmd.extend(passthrough) + return subprocess.call(cmd) + + +def main() -> None: + parser = argparse.ArgumentParser(prog="coderunner") + subparsers = parser.add_subparsers(dest="command", required=True) + + claude_parser = subparsers.add_parser( + "claude", + help="Run Claude Code inside an Apple container", + ) + claude_parser.add_argument( + "--image", + default=DEFAULT_IMAGE, + help=f"Container image (default: {DEFAULT_IMAGE})", + ) + claude_parser.add_argument( + "--container", + default=DEFAULT_CONTAINER, + help=f"Container name (default: {DEFAULT_CONTAINER})", + ) + claude_parser.add_argument( + "--workdir", + default=os.getcwd(), + help="Host directory to mount into /workspace", + ) + claude_parser.add_argument( + "--no-mount", + action="store_true", + help="Do not mount the host working directory", + ) + + args, passthrough = parser.parse_known_args() + + if args.command == "claude": + raise SystemExit(_run_claude(args, passthrough)) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 1e2eac7..2b42de8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ Homepage = "https://github.com/instavm/coderunner" Repository = "https://github.com/instavm/coderunner" Issues = "https://github.com/instavm/coderunner/issues" +[project.scripts] +coderunner = "coderunner.cli:main" + [project.optional-dependencies] dev = [ "pytest>=7.0", From 9d24d145cb109be6fb60bd0a557f17304384fb10 Mon Sep 17 00:00:00 2001 From: mkagenius Date: Sat, 7 Feb 2026 03:19:56 +0530 Subject: [PATCH 06/16] Ignore ssh artifacts --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fa5fdf7..0fa1b90 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ Thumbs.db # Personal config files (use example files instead) claude_mcp_proxy/claude_desktop_config.json +ssh/ # Runtime files *.pid @@ -65,4 +66,4 @@ jupyter_runtime/ node_modules/ -cleaup.sh \ No newline at end of file +cleaup.sh From 24abe5ec7fcd8d208ea6374f91fb343f1402bed2 Mon Sep 17 00:00:00 2001 From: mkagenius Date: Sat, 7 Feb 2026 03:42:15 +0530 Subject: [PATCH 07/16] Add litebox claude compose --- README.md | 12 ++++++++++++ docker-compose.litebox-claude.yml | 14 ++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 docker-compose.litebox-claude.yml diff --git a/README.md b/README.md index a89db98..0bb69dd 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,18 @@ Notes: - Set `ANTHROPIC_API_KEY` in your environment for non-interactive usage. - Override the image with `CODERUNNER_IMAGE` or `--image` if you tag it differently. +### LiteBox Claude (Docker) + +Run Claude inside a LiteBox-style Docker container: + +```bash +docker compose -f docker-compose.litebox-claude.yml run --rm litebox-claude +``` + +Notes: +- The container mounts the current repo into `/workspace`. +- Set `ANTHROPIC_API_KEY` in your environment for non-interactive usage. + ### Python Library Install the client (server must be running locally): diff --git a/docker-compose.litebox-claude.yml b/docker-compose.litebox-claude.yml new file mode 100644 index 0000000..90a11fd --- /dev/null +++ b/docker-compose.litebox-claude.yml @@ -0,0 +1,14 @@ +services: + litebox-claude: + build: + context: . + dockerfile: Dockerfile.claude + image: litebox-claude + container_name: litebox-claude + environment: + - ANTHROPIC_API_KEY + volumes: + - .:/workspace + working_dir: /workspace + stdin_open: true + tty: true From 0efe7cde9c1e90fd09cfb001fccaccbe8aa11571 Mon Sep 17 00:00:00 2001 From: mkagenius Date: Sat, 7 Feb 2026 09:24:44 +0530 Subject: [PATCH 08/16] Add LiteBox Claude runner container --- .gitignore | 1 + Dockerfile.litebox-claude | 55 +++++++++++++++++++++++ README.md | 8 ++-- docker-compose.litebox-claude.yml | 8 +++- scripts/litebox/build_rootfs.sh | 52 ++++++++++++++++++++++ scripts/litebox/entrypoint.sh | 73 +++++++++++++++++++++++++++++++ 6 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 Dockerfile.litebox-claude create mode 100755 scripts/litebox/build_rootfs.sh create mode 100755 scripts/litebox/entrypoint.sh diff --git a/.gitignore b/.gitignore index 0fa1b90..4dc6e99 100644 --- a/.gitignore +++ b/.gitignore @@ -65,5 +65,6 @@ jupyter_runtime/ # Node modules (if any) node_modules/ +third_party/ cleaup.sh diff --git a/Dockerfile.litebox-claude b/Dockerfile.litebox-claude new file mode 100644 index 0000000..869017a --- /dev/null +++ b/Dockerfile.litebox-claude @@ -0,0 +1,55 @@ +FROM rust:stable-bookworm AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + ca-certificates \ + build-essential \ + pkg-config \ + clang \ + lld \ + python3 \ + tar \ + xz-utils \ + && rm -rf /var/lib/apt/lists/* + +# Node.js (for Claude CLI packaging) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get update && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g @anthropic-ai/claude-code \ + && npm cache clean --force + +ARG LITEBOX_REF=main +RUN git clone --depth 1 --branch ${LITEBOX_REF} https://github.com/microsoft/litebox.git /opt/litebox + +WORKDIR /opt/litebox +RUN cargo build -p litebox_runner_linux_userland --release + +# Record the Claude entrypoint path (relative to the package root) +RUN node -p "require('/usr/local/lib/node_modules/@anthropic-ai/claude-code/package.json').bin.claude" > /opt/litebox/claude_entrypoint_rel.txt + +COPY scripts/litebox/build_rootfs.sh /tmp/build_rootfs.sh +RUN chmod +x /tmp/build_rootfs.sh \ + && /tmp/build_rootfs.sh /opt/litebox/rootfs.tar + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + tar \ + && rm -rf /var/lib/apt/lists/* + +# Node binary is used as the program executed inside LiteBox (not executed on host) +COPY --from=builder /usr/bin/node /usr/bin/node + +COPY --from=builder /opt/litebox/target/release/litebox_runner_linux_userland /usr/local/bin/litebox_runner_linux_userland +COPY --from=builder /opt/litebox/rootfs.tar /opt/litebox/rootfs.tar +COPY --from=builder /opt/litebox/claude_entrypoint_rel.txt /opt/litebox/claude_entrypoint_rel.txt + +COPY scripts/litebox/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index 0bb69dd..be539eb 100644 --- a/README.md +++ b/README.md @@ -50,16 +50,18 @@ Notes: - Set `ANTHROPIC_API_KEY` in your environment for non-interactive usage. - Override the image with `CODERUNNER_IMAGE` or `--image` if you tag it differently. -### LiteBox Claude (Docker) +### LiteBox Claude (Linux) -Run Claude inside a LiteBox-style Docker container: +Run Claude using Microsoft LiteBox (Linux userland runner) inside a container: ```bash docker compose -f docker-compose.litebox-claude.yml run --rm litebox-claude ``` Notes: -- The container mounts the current repo into `/workspace`. +- This builds LiteBox from source and runs `litebox_runner_linux_userland` under Docker. +- The container mounts the current repo into `/workspace` and injects it into LiteBox by default. +- Docker seccomp is set to `unconfined` for LiteBox syscall interception. - Set `ANTHROPIC_API_KEY` in your environment for non-interactive usage. ### Python Library diff --git a/docker-compose.litebox-claude.yml b/docker-compose.litebox-claude.yml index 90a11fd..f71ef20 100644 --- a/docker-compose.litebox-claude.yml +++ b/docker-compose.litebox-claude.yml @@ -2,11 +2,17 @@ services: litebox-claude: build: context: . - dockerfile: Dockerfile.claude + dockerfile: Dockerfile.litebox-claude + args: + LITEBOX_REF: main image: litebox-claude container_name: litebox-claude environment: - ANTHROPIC_API_KEY + - LITEBOX_INCLUDE_WORKSPACE=1 + - LITEBOX_WORKSPACE_EXCLUDE_GIT=1 + security_opt: + - seccomp=unconfined volumes: - .:/workspace working_dir: /workspace diff --git a/scripts/litebox/build_rootfs.sh b/scripts/litebox/build_rootfs.sh new file mode 100755 index 0000000..8b55664 --- /dev/null +++ b/scripts/litebox/build_rootfs.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUT_TAR=${1:-/opt/litebox/rootfs.tar} +NODE_BIN=${NODE_BIN:-$(command -v node)} +CLAUDE_PKG_DIR=${CLAUDE_PKG_DIR:-/usr/local/lib/node_modules/@anthropic-ai/claude-code} + +if [ ! -x "$NODE_BIN" ]; then + echo "node binary not found at $NODE_BIN" >&2 + exit 1 +fi +if [ ! -d "$CLAUDE_PKG_DIR" ]; then + echo "Claude package not found at $CLAUDE_PKG_DIR" >&2 + exit 1 +fi + +ROOTFS_DIR=$(mktemp -d) +cleanup() { + rm -rf "$ROOTFS_DIR" +} +trap cleanup EXIT + +mkdir -p "$ROOTFS_DIR" + +# Copy shared libraries needed by node +while IFS= read -r lib; do + dest="$ROOTFS_DIR${lib}" + mkdir -p "$(dirname "$dest")" + cp -L "$lib" "$dest" +done < <(ldd "$NODE_BIN" | awk '{print $3}' | grep '^/') + +# CA certificates and basic network config +mkdir -p "$ROOTFS_DIR/etc/ssl/certs" +if [ -f /etc/ssl/certs/ca-certificates.crt ]; then + cp -L /etc/ssl/certs/ca-certificates.crt "$ROOTFS_DIR/etc/ssl/certs/" +fi +for f in /etc/hosts /etc/resolv.conf /etc/nsswitch.conf; do + if [ -f "$f" ]; then + mkdir -p "$ROOTFS_DIR/etc" + cp -L "$f" "$ROOTFS_DIR/etc/" + fi +done + +# Claude package (includes its node_modules) +mkdir -p "$ROOTFS_DIR/usr/local/lib/node_modules" +cp -a "$CLAUDE_PKG_DIR" "$ROOTFS_DIR/usr/local/lib/node_modules/" + +# Build tar (ustar allows longer names) +rm -f "$OUT_TAR" +tar --format=ustar -C "$ROOTFS_DIR" -cf "$OUT_TAR" . + +echo "Rootfs tar created at $OUT_TAR" diff --git a/scripts/litebox/entrypoint.sh b/scripts/litebox/entrypoint.sh new file mode 100755 index 0000000..7537722 --- /dev/null +++ b/scripts/litebox/entrypoint.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +RUNNER=${LITEBOX_RUNNER:-/usr/local/bin/litebox_runner_linux_userland} +ROOTFS_TAR=${LITEBOX_ROOTFS_TAR:-/opt/litebox/rootfs.tar} +NODE_BIN=${LITEBOX_NODE_BIN:-/usr/bin/node} +CLAUDE_REL_PATH_FILE=${LITEBOX_CLAUDE_REL_PATH_FILE:-/opt/litebox/claude_entrypoint_rel.txt} +WORKSPACE_DIR=${LITEBOX_WORKSPACE_DIR:-/workspace} +INCLUDE_WORKSPACE=${LITEBOX_INCLUDE_WORKSPACE:-1} +EXCLUDE_GIT=${LITEBOX_WORKSPACE_EXCLUDE_GIT:-1} + +if [ ! -x "$RUNNER" ]; then + echo "LiteBox runner not found at $RUNNER" >&2 + exit 1 +fi +if [ ! -f "$ROOTFS_TAR" ]; then + echo "Rootfs tar not found at $ROOTFS_TAR" >&2 + exit 1 +fi +if [ ! -x "$NODE_BIN" ]; then + echo "Node binary not found at $NODE_BIN" >&2 + exit 1 +fi +if [ ! -f "$CLAUDE_REL_PATH_FILE" ]; then + echo "Claude entrypoint rel path file not found at $CLAUDE_REL_PATH_FILE" >&2 + exit 1 +fi + +CLAUDE_REL_PATH=$(cat "$CLAUDE_REL_PATH_FILE") +CLAUDE_ENTRYPOINT="/usr/local/lib/node_modules/@anthropic-ai/claude-code/${CLAUDE_REL_PATH}" + +if [ "$INCLUDE_WORKSPACE" = "1" ] && [ -d "$WORKSPACE_DIR" ]; then + tmp_dir=$(mktemp -d) + cleanup() { + rm -rf "$tmp_dir" + if [ -n "${COMBINED_TAR:-}" ] && [ -f "$COMBINED_TAR" ]; then + rm -f "$COMBINED_TAR" + fi + } + trap cleanup EXIT + + tar -xf "$ROOTFS_TAR" -C "$tmp_dir" + mkdir -p "$tmp_dir/workspace" + + TAR_EXCLUDES=() + if [ "$EXCLUDE_GIT" = "1" ]; then + TAR_EXCLUDES+=(--exclude=.git) + fi + + tar -C "$WORKSPACE_DIR" "${TAR_EXCLUDES[@]}" -cf "$tmp_dir/workspace.tar" . + tar -xf "$tmp_dir/workspace.tar" -C "$tmp_dir/workspace" + + COMBINED_TAR=$(mktemp) + tar --format=ustar -C "$tmp_dir" -cf "$COMBINED_TAR" . + ROOTFS_TAR="$COMBINED_TAR" +fi + +ARGS=( + --unstable + --interception-backend seccomp + --env "HOME=/" + --env "NODE_PATH=/usr/local/lib/node_modules" + --env "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt" + --env "SSL_CERT_DIR=/etc/ssl/certs" + --env "LD_LIBRARY_PATH=/lib:/lib64:/usr/lib:/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu" + --initial-files "$ROOTFS_TAR" +) + +if [ -n "${ANTHROPIC_API_KEY:-}" ]; then + ARGS+=(--env "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}") +fi + +exec "$RUNNER" "${ARGS[@]}" "$NODE_BIN" "$CLAUDE_ENTRYPOINT" "$@" From c1fa79e8aa936774bb7c33fa79135cbedf197902 Mon Sep 17 00:00:00 2001 From: mkagenius Date: Sat, 7 Feb 2026 09:37:23 +0530 Subject: [PATCH 09/16] Fix LiteBox builder base image tag --- Dockerfile.litebox-claude | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile.litebox-claude b/Dockerfile.litebox-claude index 869017a..c378e9e 100644 --- a/Dockerfile.litebox-claude +++ b/Dockerfile.litebox-claude @@ -1,4 +1,5 @@ -FROM rust:stable-bookworm AS builder +ARG RUST_IMAGE=rust:1.77-bookworm +FROM ${RUST_IMAGE} AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ git \ From a9a068819807ec9e624606fd1bf9fbcbf92bb9ff Mon Sep 17 00:00:00 2001 From: mkagenius Date: Sat, 7 Feb 2026 10:39:58 +0530 Subject: [PATCH 10/16] Run LiteBox Claude as linux/amd64 --- README.md | 1 + docker-compose.litebox-claude.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index be539eb..e80f1ad 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Notes: - This builds LiteBox from source and runs `litebox_runner_linux_userland` under Docker. - The container mounts the current repo into `/workspace` and injects it into LiteBox by default. - Docker seccomp is set to `unconfined` for LiteBox syscall interception. +- Uses `linux/amd64` by default (LiteBox currently requires x86_64), so Docker will run under emulation on Apple Silicon. - Set `ANTHROPIC_API_KEY` in your environment for non-interactive usage. ### Python Library diff --git a/docker-compose.litebox-claude.yml b/docker-compose.litebox-claude.yml index f71ef20..c9e51a2 100644 --- a/docker-compose.litebox-claude.yml +++ b/docker-compose.litebox-claude.yml @@ -1,5 +1,6 @@ services: litebox-claude: + platform: linux/amd64 build: context: . dockerfile: Dockerfile.litebox-claude From 66a9dec399298fb3b4a287f3ba8c3b637a9d011c Mon Sep 17 00:00:00 2001 From: mkagenius Date: Sat, 7 Feb 2026 11:49:07 +0530 Subject: [PATCH 11/16] Stabilize LiteBox build under emulation --- Dockerfile.litebox-claude | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.litebox-claude b/Dockerfile.litebox-claude index c378e9e..36e5c41 100644 --- a/Dockerfile.litebox-claude +++ b/Dockerfile.litebox-claude @@ -26,7 +26,7 @@ ARG LITEBOX_REF=main RUN git clone --depth 1 --branch ${LITEBOX_REF} https://github.com/microsoft/litebox.git /opt/litebox WORKDIR /opt/litebox -RUN cargo build -p litebox_runner_linux_userland --release +RUN RUST_MIN_STACK=16777216 CARGO_BUILD_JOBS=1 cargo build -p litebox_runner_linux_userland --release # Record the Claude entrypoint path (relative to the package root) RUN node -p "require('/usr/local/lib/node_modules/@anthropic-ai/claude-code/package.json').bin.claude" > /opt/litebox/claude_entrypoint_rel.txt From fb4d4583e25b6e6ae01d23da91652b875d835088 Mon Sep 17 00:00:00 2001 From: mkagenius Date: Sat, 7 Feb 2026 13:56:51 +0530 Subject: [PATCH 12/16] Use prebuilt LiteBox runner --- Dockerfile.litebox-claude | 20 +++++--------------- README.md | 5 +++-- docker-compose.litebox-claude.yml | 2 -- scripts/litebox/entrypoint.sh | 2 +- 4 files changed, 9 insertions(+), 20 deletions(-) diff --git a/Dockerfile.litebox-claude b/Dockerfile.litebox-claude index 36e5c41..0af88d1 100644 --- a/Dockerfile.litebox-claude +++ b/Dockerfile.litebox-claude @@ -1,15 +1,9 @@ -ARG RUST_IMAGE=rust:1.77-bookworm -FROM ${RUST_IMAGE} AS builder +FROM debian:bookworm-slim AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ + bash \ curl \ ca-certificates \ - build-essential \ - pkg-config \ - clang \ - lld \ - python3 \ tar \ xz-utils \ && rm -rf /var/lib/apt/lists/* @@ -22,11 +16,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ RUN npm install -g @anthropic-ai/claude-code \ && npm cache clean --force -ARG LITEBOX_REF=main -RUN git clone --depth 1 --branch ${LITEBOX_REF} https://github.com/microsoft/litebox.git /opt/litebox - -WORKDIR /opt/litebox -RUN RUST_MIN_STACK=16777216 CARGO_BUILD_JOBS=1 cargo build -p litebox_runner_linux_userland --release +RUN mkdir -p /opt/litebox # Record the Claude entrypoint path (relative to the package root) RUN node -p "require('/usr/local/lib/node_modules/@anthropic-ai/claude-code/package.json').bin.claude" > /opt/litebox/claude_entrypoint_rel.txt @@ -46,11 +36,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Node binary is used as the program executed inside LiteBox (not executed on host) COPY --from=builder /usr/bin/node /usr/bin/node -COPY --from=builder /opt/litebox/target/release/litebox_runner_linux_userland /usr/local/bin/litebox_runner_linux_userland +COPY third_party/litebox-bin/litebox_runner_linux_userland /usr/local/bin/litebox_runner_linux_userland COPY --from=builder /opt/litebox/rootfs.tar /opt/litebox/rootfs.tar COPY --from=builder /opt/litebox/claude_entrypoint_rel.txt /opt/litebox/claude_entrypoint_rel.txt COPY scripts/litebox/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +RUN chmod +x /usr/local/bin/litebox_runner_linux_userland /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index e80f1ad..274475e 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,11 @@ docker compose -f docker-compose.litebox-claude.yml run --rm litebox-claude ``` Notes: -- This builds LiteBox from source and runs `litebox_runner_linux_userland` under Docker. +- Requires a prebuilt LiteBox runner binary at `third_party/litebox-bin/litebox_runner_linux_userland` (build on x86_64 Linux). +- Example build on x86_64: `git clone https://github.com/microsoft/litebox.git && cd litebox && cargo build -p litebox_runner_linux_userland --release` then copy `target/release/litebox_runner_linux_userland` into that path. - The container mounts the current repo into `/workspace` and injects it into LiteBox by default. - Docker seccomp is set to `unconfined` for LiteBox syscall interception. -- Uses `linux/amd64` by default (LiteBox currently requires x86_64), so Docker will run under emulation on Apple Silicon. +- Uses `linux/amd64` by default (LiteBox currently requires x86_64). The runner itself should be built on native x86_64 to avoid QEMU rustc crashes. - Set `ANTHROPIC_API_KEY` in your environment for non-interactive usage. ### Python Library diff --git a/docker-compose.litebox-claude.yml b/docker-compose.litebox-claude.yml index c9e51a2..e23db5a 100644 --- a/docker-compose.litebox-claude.yml +++ b/docker-compose.litebox-claude.yml @@ -4,8 +4,6 @@ services: build: context: . dockerfile: Dockerfile.litebox-claude - args: - LITEBOX_REF: main image: litebox-claude container_name: litebox-claude environment: diff --git a/scripts/litebox/entrypoint.sh b/scripts/litebox/entrypoint.sh index 7537722..d666f49 100755 --- a/scripts/litebox/entrypoint.sh +++ b/scripts/litebox/entrypoint.sh @@ -62,7 +62,7 @@ ARGS=( --env "NODE_PATH=/usr/local/lib/node_modules" --env "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt" --env "SSL_CERT_DIR=/etc/ssl/certs" - --env "LD_LIBRARY_PATH=/lib:/lib64:/usr/lib:/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu" + --env "LD_LIBRARY_PATH=/lib:/lib64:/usr/lib:/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu:/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu" --initial-files "$ROOTFS_TAR" ) From c713d6a7a74d7c346005b8daa88cf883774472a6 Mon Sep 17 00:00:00 2001 From: mkagenius Date: Sat, 7 Feb 2026 14:02:22 +0530 Subject: [PATCH 13/16] Fix claude package path in LiteBox build --- Dockerfile.litebox-claude | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile.litebox-claude b/Dockerfile.litebox-claude index 0af88d1..5c9b77b 100644 --- a/Dockerfile.litebox-claude +++ b/Dockerfile.litebox-claude @@ -19,11 +19,12 @@ RUN npm install -g @anthropic-ai/claude-code \ RUN mkdir -p /opt/litebox # Record the Claude entrypoint path (relative to the package root) -RUN node -p "require('/usr/local/lib/node_modules/@anthropic-ai/claude-code/package.json').bin.claude" > /opt/litebox/claude_entrypoint_rel.txt +RUN CLAUDE_PKG_DIR="$(npm root -g)/@anthropic-ai/claude-code" \ + && node -p "require('${CLAUDE_PKG_DIR}/package.json').bin.claude" > /opt/litebox/claude_entrypoint_rel.txt COPY scripts/litebox/build_rootfs.sh /tmp/build_rootfs.sh RUN chmod +x /tmp/build_rootfs.sh \ - && /tmp/build_rootfs.sh /opt/litebox/rootfs.tar + && CLAUDE_PKG_DIR="$(npm root -g)/@anthropic-ai/claude-code" /tmp/build_rootfs.sh /opt/litebox/rootfs.tar FROM debian:bookworm-slim From 598792650a5d04d9fc767bba3751439c75dcd01e Mon Sep 17 00:00:00 2001 From: mkagenius Date: Sat, 7 Feb 2026 18:31:31 +0530 Subject: [PATCH 14/16] Fix LiteBox Claude runtime packaging --- Dockerfile.litebox-claude | 42 +++++++++++++++++++++++++++++---- README.md | 1 + scripts/litebox/build_rootfs.sh | 11 ++++++--- scripts/litebox/entrypoint.sh | 25 +++++++++++++++----- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/Dockerfile.litebox-claude b/Dockerfile.litebox-claude index 5c9b77b..a1e9856 100644 --- a/Dockerfile.litebox-claude +++ b/Dockerfile.litebox-claude @@ -13,18 +13,48 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get update && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* +ENV NPM_CONFIG_PREFIX=/usr/local + RUN npm install -g @anthropic-ai/claude-code \ && npm cache clean --force RUN mkdir -p /opt/litebox -# Record the Claude entrypoint path (relative to the package root) -RUN CLAUDE_PKG_DIR="$(npm root -g)/@anthropic-ai/claude-code" \ - && node -p "require('${CLAUDE_PKG_DIR}/package.json').bin.claude" > /opt/litebox/claude_entrypoint_rel.txt +# Record the Claude package directory + entrypoint path +RUN set -e; \ + NPM_ROOT="$(npm root -g)"; \ + if [ -d "${NPM_ROOT}/claude-code" ]; then \ + export CLAUDE_PKG_DIR="${NPM_ROOT}/claude-code"; \ + elif [ -d "${NPM_ROOT}/@anthropic-ai/claude-code" ]; then \ + export CLAUDE_PKG_DIR="${NPM_ROOT}/@anthropic-ai/claude-code"; \ + else \ + echo "Claude Code package not found under ${NPM_ROOT}" >&2; \ + exit 1; \ + fi; \ + rm -rf /opt/litebox/claude-code; \ + cp -a "${CLAUDE_PKG_DIR}" /opt/litebox/claude-code; \ + export CLAUDE_ROOTFS_PKG_DIR="/usr/local/lib/node_modules/claude-code"; \ + node - <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const srcDir = process.env.CLAUDE_PKG_DIR; +const rootfsDir = process.env.CLAUDE_ROOTFS_PKG_DIR; +if (!srcDir || !rootfsDir) { + throw new Error('Missing CLAUDE_PKG_DIR or CLAUDE_ROOTFS_PKG_DIR'); +} +const pkg = require(path.join(srcDir, 'package.json')); +const binRel = pkg?.bin?.claude; +if (!binRel) { + throw new Error('Claude Code bin entry not found'); +} +fs.writeFileSync('/opt/litebox/claude_pkg_dir.txt', '/opt/litebox/claude-code'); +fs.writeFileSync('/opt/litebox/claude_entrypoint_path.txt', path.join(rootfsDir, binRel)); +NODE COPY scripts/litebox/build_rootfs.sh /tmp/build_rootfs.sh RUN chmod +x /tmp/build_rootfs.sh \ - && CLAUDE_PKG_DIR="$(npm root -g)/@anthropic-ai/claude-code" /tmp/build_rootfs.sh /opt/litebox/rootfs.tar + && CLAUDE_PKG_DIR="$(cat /opt/litebox/claude_pkg_dir.txt)" /tmp/build_rootfs.sh /opt/litebox/rootfs.tar FROM debian:bookworm-slim @@ -36,10 +66,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Node binary is used as the program executed inside LiteBox (not executed on host) COPY --from=builder /usr/bin/node /usr/bin/node +RUN mkdir -p /usr/local/lib/node_modules COPY third_party/litebox-bin/litebox_runner_linux_userland /usr/local/bin/litebox_runner_linux_userland +COPY --from=builder /opt/litebox/claude-code /usr/local/lib/node_modules/claude-code COPY --from=builder /opt/litebox/rootfs.tar /opt/litebox/rootfs.tar -COPY --from=builder /opt/litebox/claude_entrypoint_rel.txt /opt/litebox/claude_entrypoint_rel.txt +COPY --from=builder /opt/litebox/claude_entrypoint_path.txt /opt/litebox/claude_entrypoint_path.txt COPY scripts/litebox/entrypoint.sh /entrypoint.sh RUN chmod +x /usr/local/bin/litebox_runner_linux_userland /entrypoint.sh diff --git a/README.md b/README.md index 274475e..f5f3a06 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Notes: - Example build on x86_64: `git clone https://github.com/microsoft/litebox.git && cd litebox && cargo build -p litebox_runner_linux_userland --release` then copy `target/release/litebox_runner_linux_userland` into that path. - The container mounts the current repo into `/workspace` and injects it into LiteBox by default. - Docker seccomp is set to `unconfined` for LiteBox syscall interception. +- Default interception backend is `rewriter` (more compatible with Docker Desktop); set `LITEBOX_INTERCEPTION_BACKEND=seccomp` to use seccomp on a Linux host. - Uses `linux/amd64` by default (LiteBox currently requires x86_64). The runner itself should be built on native x86_64 to avoid QEMU rustc crashes. - Set `ANTHROPIC_API_KEY` in your environment for non-interactive usage. diff --git a/scripts/litebox/build_rootfs.sh b/scripts/litebox/build_rootfs.sh index 8b55664..d47577e 100755 --- a/scripts/litebox/build_rootfs.sh +++ b/scripts/litebox/build_rootfs.sh @@ -3,7 +3,7 @@ set -euo pipefail OUT_TAR=${1:-/opt/litebox/rootfs.tar} NODE_BIN=${NODE_BIN:-$(command -v node)} -CLAUDE_PKG_DIR=${CLAUDE_PKG_DIR:-/usr/local/lib/node_modules/@anthropic-ai/claude-code} +CLAUDE_PKG_DIR=${CLAUDE_PKG_DIR:-/usr/local/lib/node_modules/claude-code} if [ ! -x "$NODE_BIN" ]; then echo "node binary not found at $NODE_BIN" >&2 @@ -22,12 +22,17 @@ trap cleanup EXIT mkdir -p "$ROOTFS_DIR" -# Copy shared libraries needed by node +# Copy shared libraries (including the ELF interpreter) needed by node while IFS= read -r lib; do dest="$ROOTFS_DIR${lib}" mkdir -p "$(dirname "$dest")" cp -L "$lib" "$dest" -done < <(ldd "$NODE_BIN" | awk '{print $3}' | grep '^/') +done < <(ldd "$NODE_BIN" | awk '{for (i=1;i<=NF;i++) if (index($i, "/") == 1) print $i}' | sort -u) + +# Include the Node binary itself +node_dest="$ROOTFS_DIR${NODE_BIN}" +mkdir -p "$(dirname "$node_dest")" +cp -L "$NODE_BIN" "$node_dest" # CA certificates and basic network config mkdir -p "$ROOTFS_DIR/etc/ssl/certs" diff --git a/scripts/litebox/entrypoint.sh b/scripts/litebox/entrypoint.sh index d666f49..d70ce69 100755 --- a/scripts/litebox/entrypoint.sh +++ b/scripts/litebox/entrypoint.sh @@ -4,10 +4,12 @@ set -euo pipefail RUNNER=${LITEBOX_RUNNER:-/usr/local/bin/litebox_runner_linux_userland} ROOTFS_TAR=${LITEBOX_ROOTFS_TAR:-/opt/litebox/rootfs.tar} NODE_BIN=${LITEBOX_NODE_BIN:-/usr/bin/node} -CLAUDE_REL_PATH_FILE=${LITEBOX_CLAUDE_REL_PATH_FILE:-/opt/litebox/claude_entrypoint_rel.txt} +CLAUDE_ENTRYPOINT_FILE=${LITEBOX_CLAUDE_ENTRYPOINT_FILE:-/opt/litebox/claude_entrypoint_path.txt} WORKSPACE_DIR=${LITEBOX_WORKSPACE_DIR:-/workspace} INCLUDE_WORKSPACE=${LITEBOX_INCLUDE_WORKSPACE:-1} EXCLUDE_GIT=${LITEBOX_WORKSPACE_EXCLUDE_GIT:-1} +INTERCEPTION_BACKEND=${LITEBOX_INTERCEPTION_BACKEND:-rewriter} +REWRITE_SYSCALLS=${LITEBOX_REWRITE_SYSCALLS:-} if [ ! -x "$RUNNER" ]; then echo "LiteBox runner not found at $RUNNER" >&2 @@ -21,13 +23,12 @@ if [ ! -x "$NODE_BIN" ]; then echo "Node binary not found at $NODE_BIN" >&2 exit 1 fi -if [ ! -f "$CLAUDE_REL_PATH_FILE" ]; then - echo "Claude entrypoint rel path file not found at $CLAUDE_REL_PATH_FILE" >&2 +if [ ! -f "$CLAUDE_ENTRYPOINT_FILE" ]; then + echo "Claude entrypoint path file not found at $CLAUDE_ENTRYPOINT_FILE" >&2 exit 1 fi -CLAUDE_REL_PATH=$(cat "$CLAUDE_REL_PATH_FILE") -CLAUDE_ENTRYPOINT="/usr/local/lib/node_modules/@anthropic-ai/claude-code/${CLAUDE_REL_PATH}" +CLAUDE_ENTRYPOINT=$(cat "$CLAUDE_ENTRYPOINT_FILE") if [ "$INCLUDE_WORKSPACE" = "1" ] && [ -d "$WORKSPACE_DIR" ]; then tmp_dir=$(mktemp -d) @@ -55,9 +56,17 @@ if [ "$INCLUDE_WORKSPACE" = "1" ] && [ -d "$WORKSPACE_DIR" ]; then ROOTFS_TAR="$COMBINED_TAR" fi +if [ -z "$REWRITE_SYSCALLS" ]; then + if [ "$INTERCEPTION_BACKEND" = "rewriter" ]; then + REWRITE_SYSCALLS=1 + else + REWRITE_SYSCALLS=0 + fi +fi + ARGS=( --unstable - --interception-backend seccomp + --interception-backend "$INTERCEPTION_BACKEND" --env "HOME=/" --env "NODE_PATH=/usr/local/lib/node_modules" --env "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt" @@ -66,6 +75,10 @@ ARGS=( --initial-files "$ROOTFS_TAR" ) +if [ "$REWRITE_SYSCALLS" = "1" ]; then + ARGS+=(--rewrite-syscalls) +fi + if [ -n "${ANTHROPIC_API_KEY:-}" ]; then ARGS+=(--env "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}") fi From 6dd1e7bc6ec81ee19ddc03518e041ef45ea9afcc Mon Sep 17 00:00:00 2001 From: mkagenius Date: Sat, 7 Feb 2026 19:01:42 +0530 Subject: [PATCH 15/16] Document LiteBox macOS emulation limitation --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f5f3a06..a13c7cf 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Notes: - Docker seccomp is set to `unconfined` for LiteBox syscall interception. - Default interception backend is `rewriter` (more compatible with Docker Desktop); set `LITEBOX_INTERCEPTION_BACKEND=seccomp` to use seccomp on a Linux host. - Uses `linux/amd64` by default (LiteBox currently requires x86_64). The runner itself should be built on native x86_64 to avoid QEMU rustc crashes. +- On macOS with Docker Desktop `linux/amd64` emulation, the LiteBox runner currently segfaults; use a native x86_64 Linux host/VM. - Set `ANTHROPIC_API_KEY` in your environment for non-interactive usage. ### Python Library From 79b136c4d38115c515d807fa81d7bf444bf6801d Mon Sep 17 00:00:00 2001 From: mkagenius Date: Tue, 7 Apr 2026 22:04:25 +0530 Subject: [PATCH 16/16] Add complete third-party license notices for all dependencies Adds legally required attribution notices for all MIT, BSD, PSF, MPL-2.0, and LGPLv3 dependencies from requirements.txt. Apache-2.0 dependencies are omitted since the project itself is Apache-2.0 licensed. Grouped by license type: - MIT License (12 components) - MPL-2.0 AND MIT (tqdm) - BSD 3-Clause (8 components) - BSD 2-Clause (3 components, was 2) - BSD unspecified (10 components) - Python Software Foundation License (matplotlib) - LGPLv3 (img2pdf) - Existing: GPLv3 (FFmpeg), MIT-CMU (Pillow), SIL OFL (fonts) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- THIRD_PARTY_NOTICES.md | 126 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5279b9b..a4702f0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,6 +4,86 @@ THE FOLLOWING SETS FORTH ATTRIBUTION NOTICES FOR THIRD PARTY SOFTWARE THAT MAY B --- +## **MIT License** + +The following components are licensed under the MIT License reproduced below: + +**beautifulsoup4 4.14.3**, Copyright (c) Leonard Richardson + +**duckdb 1.5.1**, Copyright (c) DuckDB Foundation + +**fastapi 0.135.3**, Copyright (c) SebastiΓ‘n RamΓ­rez + +**mcp 1.27.0**, Copyright (c) Anthropic, PBC. + +**openpyxl 3.1.5**, Copyright (c) openpyxl contributors + +**pdf2image 1.17.0**, Copyright (c) Edouard Belval + +**pdfkit 1.0.0**, Copyright (c) Golovanov Stanislav + +**pdfplumber 0.11.9**, Copyright (c) Jeremy Singer-Vine + +**python-docx 1.2.0**, Copyright (c) Steve Canny + +**python-pptx 1.0.2**, Copyright (c) Steve Canny + +**pytz 2026.1.post1**, Copyright (c) Stuart Bishop + +**tabula-py 2.10.0**, Copyright (c) Aki Ariga + +**License Text:** + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## **MPL-2.0 AND MIT License** + +The following component is dual-licensed under the Mozilla Public License 2.0 and MIT License: + +**tqdm 4.67.3**, Copyright (c) tqdm contributors + +--- + +## **BSD 3-Clause License** + +The following components are licensed under the BSD 3-Clause License reproduced below: + +**httpx 0.28.1**, Copyright (c) Tom Christie + +**joblib 1.5.3**, Copyright (c) Gael Varoquaux + +**mpmath 1.4.1**, Copyright (c) Fredrik Johansson + +**numpy 2.4.4**, Copyright (c) NumPy Developers + +**pypdf 6.9.2**, Copyright (c) pypdf contributors + +**scikit-learn 1.8.0**, Copyright (c) scikit-learn developers + +**uvicorn 0.44.0**, Copyright (c) Tom Christie + +**websockets 16.0**, Copyright (c) Aymeric Augustin + +**License Text:** + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + ## **BSD 2-Clause License** The following components are licensed under BSD 2-Clause License reproduced below: @@ -12,6 +92,8 @@ The following components are licensed under BSD 2-Clause License reproduced belo **imageio-ffmpeg 0.6.0**, Copyright (c) 2019-2025, imageio +**xlsxwriter 3.2.9**, Copyright (c) John McNamara + **License Text:** Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: @@ -24,6 +106,50 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND --- +## **BSD License** + +The following components are licensed under a BSD-style license: + +**bash_kernel 0.10.0**, Copyright (c) Thomas Kluyver + +**jupyter-server 2.17.0**, Copyright (c) Jupyter Development Team + +**pandas 3.0.2**, Copyright (c) The Pandas Development Team + +**python-dateutil 2.9.0.post0**, Copyright (c) Gustavo Niemeyer + +**reportlab 4.4.10**, Copyright (c) 2000-2025, ReportLab Inc. + +**scipy 1.17.1**, Copyright (c) SciPy Developers + +**seaborn 0.13.2**, Copyright (c) Michael Waskom + +**statsmodels 0.14.6**, Copyright (c) statsmodels Developers + +**sympy 1.14.0**, Copyright (c) SymPy development team + +**xlrd 2.0.2**, Copyright (c) Chris Withers + +--- + +## **Python Software Foundation License** + +The following components are licensed under the Python Software Foundation License: + +**matplotlib 3.10.8**, Copyright (c) John D. Hunter, Michael Droettboom + +--- + +## **GNU Lesser General Public License v3 (LGPLv3)** + +The following components are licensed under the GNU Lesser General Public License v3: + +**img2pdf 0.6.3**, Copyright (c) Johannes Schauer Marin Rodrigues + +Note: This component is used as a library only. Under the terms of the LGPLv3, users may replace or modify this library. Source code is available at https://pypi.org/project/img2pdf/ + +--- + ## **GNU General Public License v3.0** The following components are licensed under GNU General Public License v3.0 reproduced below: