github-actions[bot] commited on
Commit ·
89d0fd3
0
Parent(s):
Deploy from 2528090f
Browse files- Dockerfile +20 -0
- README.md +327 -0
- app/__init__.py +0 -0
- app/main.py +232 -0
- app/mcp_sandbox.py +567 -0
- app/runner_client.py +36 -0
- app/sandbox.py +316 -0
- app/static/.gitkeep +0 -0
- app/static/space/data.jsx +387 -0
- app/static/space/fx.jsx +179 -0
- app/static/space/hf-console.jsx +263 -0
- app/static/space/hf-space.jsx +21 -0
- app/static/space/hf-theme.css +209 -0
- app/static/space/index.html +56 -0
- app/static/space/sandbox.jsx +279 -0
- app/templates/home.html +119 -0
- app/templates/result.html +88 -0
- requirements.txt +7 -0
- scripts/sandbox_concurrency_smoke.py +93 -0
Dockerfile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 4 |
+
git curl ca-certificates nodejs npm \
|
| 5 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 6 |
+
|
| 7 |
+
RUN python -m pip install --no-cache-dir pipx uv && python -m pipx ensurepath
|
| 8 |
+
ENV PATH="/root/.local/bin:${PATH}"
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
ENV PORT=7860
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
|
| 20 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MatrixLab Sandbox
|
| 3 |
+
emoji: 🧪
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
license: apache-2.0
|
| 9 |
+
short_description: MatrixLab HF backend for AI repo testing and debugging
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# MatrixLab HF Backend (Space)
|
| 13 |
+
|
| 14 |
+
This Hugging Face Space is a **microservice frontend** for MatrixLab.
|
| 15 |
+
|
| 16 |
+
The Space home page (`/`) is the **Matrix CLI Console** — and **only** the
|
| 17 |
+
console. Because this runs inside a real Hugging Face Space, HF already renders
|
| 18 |
+
the page chrome (owner/repo, App·Files·Community tabs, the **Running** badge,
|
| 19 |
+
Settings/Restart), so MatrixLab does **not** duplicate that navigation or the
|
| 20 |
+
running/online status. The app is one console, one status pill, one input. The
|
| 21 |
+
legacy ZIP-verification UI moved to **`/verify`**.
|
| 22 |
+
|
| 23 |
+
Trialing a server is a first-class command rather than a separate toggle:
|
| 24 |
+
`matrix mcp test [name]` (or the **Test in sandbox** chip) starts a real
|
| 25 |
+
ephemeral `/mcp/*` session, streams the lifecycle, and prints a verdict.
|
| 26 |
+
|
| 27 |
+
It supports these modes:
|
| 28 |
+
1. **MatrixLab Space UI** (`/`) — Matrix CLI Console + one-click sandbox testing.
|
| 29 |
+
2. **Upload ZIP** (`/verify`) for static verification (syntax/security/basic tests).
|
| 30 |
+
3. **Remote GitHub execution** through MatrixLab Runner using environment bootstrap + cached task runs.
|
| 31 |
+
|
| 32 |
+
## Matrix CLI Console & the embeddable sandbox button
|
| 33 |
+
|
| 34 |
+
The page lives under `app/static/space/`:
|
| 35 |
+
|
| 36 |
+
- `index.html` — loads React (CDN) + the components below.
|
| 37 |
+
- `hf-theme.css`, `fx.jsx` (digital rain / typewriter), `data.jsx` (catalog + CLI engine).
|
| 38 |
+
- `hf-console.jsx` — the Matrix CLI Console (the whole in-Space UI).
|
| 39 |
+
- `hf-space.jsx` — a thin shell that renders **only** the console (no duplicated
|
| 40 |
+
HF chrome).
|
| 41 |
+
- **`sandbox.jsx`** — the reusable sandbox client (`window.MatrixLabSandbox`) plus
|
| 42 |
+
**`<SandboxButton>`** / `mountSandboxButton`.
|
| 43 |
+
|
| 44 |
+
Inside the Space there is **no separate "enable sandbox" toggle** — `matrix mcp
|
| 45 |
+
test` runs the real flow directly (the Space *is* the sandbox server). The
|
| 46 |
+
`<SandboxButton>` / `mountSandboxButton` export is still shipped so **matrixhub.io**
|
| 47 |
+
can embed a one-click toggle elsewhere (see "Embedding the button" below).
|
| 48 |
+
|
| 49 |
+
### Embedding the button in matrixhub.io (later)
|
| 50 |
+
|
| 51 |
+
`sandbox.jsx` is intentionally self-contained so the marketplace can drop the
|
| 52 |
+
button in with one call. It exposes `window.MatrixLabSandbox` (a framework-agnostic
|
| 53 |
+
client) and `window.mountSandboxButton`:
|
| 54 |
+
|
| 55 |
+
```html
|
| 56 |
+
<div id="sbx"></div>
|
| 57 |
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
| 58 |
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
| 59 |
+
<script src="https://ruslanmv-matrixlab.hf.space/static/space/sandbox.jsx"></script>
|
| 60 |
+
<script>
|
| 61 |
+
// Point at the Space (or your own proxy) and embed the toggle.
|
| 62 |
+
MatrixLabSandbox.configure({ baseUrl: "https://ruslanmv-matrixlab.hf.space" });
|
| 63 |
+
mountSandboxButton(document.getElementById("sbx"), {
|
| 64 |
+
onChange: (on) => console.log("sandbox enabled:", on),
|
| 65 |
+
});
|
| 66 |
+
</script>
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
Programmatic use (no button):
|
| 70 |
+
|
| 71 |
+
```js
|
| 72 |
+
MatrixLabSandbox.configure({ baseUrl: "https://ruslanmv-matrixlab.hf.space" });
|
| 73 |
+
await MatrixLabSandbox.run(
|
| 74 |
+
{ entity_id: "mcp_server:filesystem",
|
| 75 |
+
start_command: "npx -y @modelcontextprotocol/server-filesystem /tmp" },
|
| 76 |
+
(ev) => console.log(ev.step, ev.status, ev.message) // { step, status, message, data }
|
| 77 |
+
);
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
Notes for cross-origin (matrixhub.io) embedding:
|
| 81 |
+
- The Space enforces its own `MATRIXLAB_SANDBOX_TOKEN` server-side. For browser
|
| 82 |
+
embedding, prefer a **MatrixHub backend proxy** (set `baseUrl` to the proxy) so
|
| 83 |
+
no token is exposed; or supply `getToken: async () => "<short-lived>"`.
|
| 84 |
+
- Enable CORS on the proxy/Space for the marketplace origin.
|
| 85 |
+
|
| 86 |
+
## Production Goal
|
| 87 |
+
|
| 88 |
+
Use this Space as a backend entrypoint for testing and debugging AI/code repos, including:
|
| 89 |
+
- `https://github.com/ruslanmv/gitpilot`
|
| 90 |
+
- `https://github.com/ruslanmv/agent-generator`
|
| 91 |
+
- `https://github.com/ruslanmv/RepoGuardian`
|
| 92 |
+
|
| 93 |
+
The Space sends workload requests to MatrixLab Runner (`MATRIXLAB_RUNNER_URL`), which executes in isolated containers.
|
| 94 |
+
|
| 95 |
+
## Environment Variables
|
| 96 |
+
|
| 97 |
+
Set these in HF Space settings:
|
| 98 |
+
|
| 99 |
+
- `MATRIXLAB_RUNNER_URL` (required): e.g. `https://your-runner.example.com`
|
| 100 |
+
- `MATRIXLAB_RUNNER_TIMEOUT_S` (optional, default `120`)
|
| 101 |
+
|
| 102 |
+
## API
|
| 103 |
+
|
| 104 |
+
### Health
|
| 105 |
+
```bash
|
| 106 |
+
GET /health
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
### List repo profiles
|
| 110 |
+
```bash
|
| 111 |
+
GET /profiles
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### Run GitHub repo task through MatrixLab Runner
|
| 115 |
+
```bash
|
| 116 |
+
POST /repo/run
|
| 117 |
+
Content-Type: application/json
|
| 118 |
+
|
| 119 |
+
{
|
| 120 |
+
"environment_id": "gitpilot-main",
|
| 121 |
+
"profile": "gitpilot",
|
| 122 |
+
"repo_url": "https://github.com/ruslanmv/gitpilot",
|
| 123 |
+
"default_branch": "main",
|
| 124 |
+
"branch": "main",
|
| 125 |
+
"force_rebuild": false
|
| 126 |
+
}
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
Profiles:
|
| 130 |
+
- `gitpilot`
|
| 131 |
+
- `agent-generator`
|
| 132 |
+
- `repoguardian`
|
| 133 |
+
- `custom` (provide your own `repo_url` + scripts)
|
| 134 |
+
|
| 135 |
+
### ZIP verification mode (local in Space)
|
| 136 |
+
```bash
|
| 137 |
+
POST /runs # upload zip multipart
|
| 138 |
+
GET /runs
|
| 139 |
+
GET /runs/{id}
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
## Local Run
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
cd hf
|
| 146 |
+
docker build -t matrixlab-hf-space .
|
| 147 |
+
docker run -p 7860:7860 -e MATRIXLAB_RUNNER_URL=http://host.docker.internal:8000 matrixlab-hf-space
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
## Notes
|
| 151 |
+
|
| 152 |
+
- This Space is intentionally lightweight and acts as control-plane API/UI.
|
| 153 |
+
- Containerized build/test execution happens in MatrixLab Runner.
|
| 154 |
+
- For production, put authentication + rate-limiting in front of `/repo/run`.
|
| 155 |
+
- For organization-wide maintenance sweeps, use `tools/matrix_maintainer.py` with `configs/agent_matrix_repos.json`.
|
| 156 |
+
|
| 157 |
+
---
|
| 158 |
+
|
| 159 |
+
## MatrixHub MCP 10-minute sandbox mode
|
| 160 |
+
|
| 161 |
+
This Space can also run as a short-lived MCP server test worker for MatrixHub.io.
|
| 162 |
+
|
| 163 |
+
### Flow
|
| 164 |
+
|
| 165 |
+
1. MatrixHub.io user clicks **Test MCP Server**.
|
| 166 |
+
2. MatrixHub backend validates the MatrixHub entity manifest.
|
| 167 |
+
3. Backend restarts/wakes this Hugging Face Space if needed.
|
| 168 |
+
4. Backend calls `POST /mcp/sessions` with a curated install/start command.
|
| 169 |
+
5. The Space starts the MCP server over `stdio`, sends `initialize`, then `tools/list`.
|
| 170 |
+
6. MatrixHub proxies tool tests through this Space for up to 600 seconds.
|
| 171 |
+
7. The Space kills the MCP process and deletes `/tmp` session data.
|
| 172 |
+
8. MatrixHub backend may call Hugging Face `pause_space()` after the session expires.
|
| 173 |
+
|
| 174 |
+
### Recommended Space secrets
|
| 175 |
+
|
| 176 |
+
- `MATRIXLAB_SANDBOX_TOKEN`: bearer token required by `/mcp/*` write/read APIs.
|
| 177 |
+
- `MATRIXLAB_MCP_MAX_TTL_SECONDS`: default `600`.
|
| 178 |
+
- `MATRIXLAB_MCP_MAX_SESSIONS`: default **`auto`** — derive a safe concurrent cap
|
| 179 |
+
from the instance specs (see Concurrency below). Set an integer to pin it.
|
| 180 |
+
|
| 181 |
+
## Concurrency — parallel sandboxes per HF instance
|
| 182 |
+
|
| 183 |
+
MatrixLab is the **main sandbox server**: each session is an independent
|
| 184 |
+
subprocess with its own workdir, event stream, and TTL, so the worker runs many
|
| 185 |
+
sandboxes in parallel. The cap is `MATRIXLAB_MCP_MAX_SESSIONS`.
|
| 186 |
+
|
| 187 |
+
With `MATRIXLAB_MCP_MAX_SESSIONS=auto` (default) the worker reads the
|
| 188 |
+
**actual instance specs** (cgroup-aware RAM + CPU count) and picks
|
| 189 |
+
`min(cpu × 2, (usable_RAM_MB) / MATRIXLAB_MCP_MB_PER_SESSION)`, capped at
|
| 190 |
+
`MATRIXLAB_MCP_MAX_SESSIONS_CEILING` (default 32). `usable_RAM` reserves ~1.5 GB
|
| 191 |
+
for the OS/uvicorn; `MATRIXLAB_MCP_MB_PER_SESSION` defaults to 700 MB.
|
| 192 |
+
|
| 193 |
+
`GET /mcp/health` reports live capacity and the detected specs:
|
| 194 |
+
|
| 195 |
+
```json
|
| 196 |
+
{ "active_sessions": 0, "max_sessions": 10, "available_slots": 10,
|
| 197 |
+
"max_ttl_seconds": 600,
|
| 198 |
+
"instance": { "cpu": 8, "total_mem_mb": 32768, "mb_per_session": 700 } }
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
Reference mapping (700 MB/session, ~1.5 GB reserved):
|
| 202 |
+
|
| 203 |
+
| HF hardware | vCPU | RAM | `auto` cap | Notes |
|
| 204 |
+
|------------------------|------|-------|-----------|--------------------------------|
|
| 205 |
+
| CPU Basic | 2 | 16 GB | ~4 | mem-bound headroom; demos |
|
| 206 |
+
| CPU Upgrade | 8 | 32 GB | ~16→**10+**| comfortably serves 10 parallel |
|
| 207 |
+
| CPU Upgrade (pinned) | 8 | 32 GB | set `=10` | explicit, predictable |
|
| 208 |
+
|
| 209 |
+
To force exactly 10 parallel sandboxes:
|
| 210 |
+
|
| 211 |
+
```text
|
| 212 |
+
MATRIXLAB_MCP_MAX_SESSIONS=10
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
Verify on a running worker:
|
| 216 |
+
|
| 217 |
+
```bash
|
| 218 |
+
BASE=$SPACE_URL N=10 python hf/scripts/sandbox_concurrency_smoke.py
|
| 219 |
+
# requested 10 · admitted 10 · running 10/10 · with tools 10 → RESULT: PASS
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
When full, `POST /mcp/sessions` returns `429` so callers can queue/retry.
|
| 223 |
+
|
| 224 |
+
### Start a session
|
| 225 |
+
|
| 226 |
+
```bash
|
| 227 |
+
curl -X POST "$SPACE_URL/mcp/sessions" \
|
| 228 |
+
-H "Authorization: Bearer $MATRIXLAB_SANDBOX_TOKEN" \
|
| 229 |
+
-H "Content-Type: application/json" \
|
| 230 |
+
-d '{
|
| 231 |
+
"entity_id": "filesystem-demo",
|
| 232 |
+
"runtime": "node",
|
| 233 |
+
"start_command": "npx -y @modelcontextprotocol/server-filesystem /tmp",
|
| 234 |
+
"transport": "stdio",
|
| 235 |
+
"ttl_seconds": 600
|
| 236 |
+
}'
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
### Poll status
|
| 240 |
+
|
| 241 |
+
```bash
|
| 242 |
+
curl -H "Authorization: Bearer $MATRIXLAB_SANDBOX_TOKEN" \
|
| 243 |
+
"$SPACE_URL/mcp/sessions/$SESSION_ID"
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
### List tools
|
| 247 |
+
|
| 248 |
+
```bash
|
| 249 |
+
curl -H "Authorization: Bearer $MATRIXLAB_SANDBOX_TOKEN" \
|
| 250 |
+
"$SPACE_URL/mcp/sessions/$SESSION_ID/tools"
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
### Call a tool
|
| 254 |
+
|
| 255 |
+
```bash
|
| 256 |
+
curl -X POST "$SPACE_URL/mcp/sessions/$SESSION_ID/tools/call" \
|
| 257 |
+
-H "Authorization: Bearer $MATRIXLAB_SANDBOX_TOKEN" \
|
| 258 |
+
-H "Content-Type: application/json" \
|
| 259 |
+
-d '{"name":"example_tool","arguments":{}}'
|
| 260 |
+
```
|
| 261 |
+
|
| 262 |
+
### MVP safety constraints
|
| 263 |
+
|
| 264 |
+
This HF mode is a compatibility sandbox, not hardened isolation. For the free MVP, use only curated MatrixHub entries and keep these defaults:
|
| 265 |
+
|
| 266 |
+
- TTL max: 600 seconds
|
| 267 |
+
- Max sessions: 1 per free Space
|
| 268 |
+
- No user secrets
|
| 269 |
+
- No arbitrary shell scripts
|
| 270 |
+
- Allow only `npx`, `uvx`, `pipx`, `python`, `python3`, and `node` commands
|
| 271 |
+
- Block `curl | bash`, `wget | bash`, `sudo`, `docker`, package-manager installs, and shell chaining
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
---
|
| 275 |
+
|
| 276 |
+
## MatrixHub 10-minute MCP sandbox worker
|
| 277 |
+
|
| 278 |
+
This Space also exposes a MatrixHub-facing MCP sandbox API. It is designed to be
|
| 279 |
+
called by the MatrixHub backend controller when a user clicks **Test in sandbox**.
|
| 280 |
+
|
| 281 |
+
### Required Space secrets
|
| 282 |
+
|
| 283 |
+
```text
|
| 284 |
+
MATRIXLAB_SANDBOX_TOKEN=<random shared secret>
|
| 285 |
+
MATRIXLAB_MCP_MAX_TTL_SECONDS=600
|
| 286 |
+
MATRIXLAB_MCP_MAX_SESSIONS=1
|
| 287 |
+
MATRIXLAB_MCP_INSTALL_TIMEOUT_SECONDS=120
|
| 288 |
+
MATRIXLAB_MCP_STARTUP_TIMEOUT_SECONDS=45
|
| 289 |
+
MATRIXLAB_MCP_RPC_TIMEOUT_SECONDS=20
|
| 290 |
+
```
|
| 291 |
+
|
| 292 |
+
### Worker endpoints
|
| 293 |
+
|
| 294 |
+
```text
|
| 295 |
+
GET /mcp/health
|
| 296 |
+
POST /mcp/sessions
|
| 297 |
+
GET /mcp/sessions/{session_id}
|
| 298 |
+
GET /mcp/sessions/{session_id}/events
|
| 299 |
+
GET /mcp/sessions/{session_id}/tools
|
| 300 |
+
POST /mcp/sessions/{session_id}/tools/call
|
| 301 |
+
DELETE /mcp/sessions/{session_id}
|
| 302 |
+
```
|
| 303 |
+
|
| 304 |
+
### Example direct test
|
| 305 |
+
|
| 306 |
+
```bash
|
| 307 |
+
curl -X POST "$SPACE_URL/mcp/sessions" \
|
| 308 |
+
-H "Authorization: Bearer $MATRIXLAB_SANDBOX_TOKEN" \
|
| 309 |
+
-H "Content-Type: application/json" \
|
| 310 |
+
-d '{
|
| 311 |
+
"entity_id": "mcp_server:filesystem",
|
| 312 |
+
"runtime": "node",
|
| 313 |
+
"start_command": "npx -y @modelcontextprotocol/server-filesystem /tmp",
|
| 314 |
+
"transport": "stdio",
|
| 315 |
+
"ttl_seconds": 600
|
| 316 |
+
}'
|
| 317 |
+
```
|
| 318 |
+
|
| 319 |
+
Then stream events:
|
| 320 |
+
|
| 321 |
+
```bash
|
| 322 |
+
curl -N "$SPACE_URL/mcp/sessions/$SESSION_ID/events" \
|
| 323 |
+
-H "Authorization: Bearer $MATRIXLAB_SANDBOX_TOKEN"
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
This worker is for curated compatibility testing only. Do not pass production
|
| 327 |
+
secrets into it and do not expose it directly to browsers.
|
app/__init__.py
ADDED
|
File without changes
|
app/main.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MatrixLab HF Space microservice for remote repo testing/debugging."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Dict, Literal
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
|
| 12 |
+
from fastapi.responses import FileResponse, HTMLResponse
|
| 13 |
+
from fastapi.staticfiles import StaticFiles
|
| 14 |
+
from fastapi.templating import Jinja2Templates
|
| 15 |
+
from pydantic import BaseModel, Field
|
| 16 |
+
|
| 17 |
+
from app.runner_client import RunnerClient
|
| 18 |
+
from app.sandbox import run_verification
|
| 19 |
+
from app.mcp_sandbox import router as mcp_sandbox_router
|
| 20 |
+
|
| 21 |
+
app = FastAPI(title="MatrixLab HF Backend", version="1.2.0")
|
| 22 |
+
app.include_router(mcp_sandbox_router)
|
| 23 |
+
|
| 24 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 25 |
+
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
| 26 |
+
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
| 27 |
+
|
| 28 |
+
# In-memory run store (zip verification)
|
| 29 |
+
runs: dict[str, dict] = {}
|
| 30 |
+
runner = RunnerClient()
|
| 31 |
+
|
| 32 |
+
PROFILE_PRESETS: Dict[str, Dict[str, Any]] = {
|
| 33 |
+
"gitpilot": {
|
| 34 |
+
"repo_url": "https://github.com/ruslanmv/gitpilot",
|
| 35 |
+
"task_command": "pytest -q || python -m unittest discover || python -m compileall .",
|
| 36 |
+
"setup_script": "if [ -f requirements.txt ]; then python -m venv .venv && . .venv/bin/activate && pip install -U pip && pip install -r requirements.txt; fi",
|
| 37 |
+
},
|
| 38 |
+
"agent-generator": {
|
| 39 |
+
"repo_url": "https://github.com/ruslanmv/agent-generator",
|
| 40 |
+
"task_command": "pytest -q || python -m unittest discover || python -m compileall .",
|
| 41 |
+
"setup_script": "if [ -f requirements.txt ]; then python -m venv .venv && . .venv/bin/activate && pip install -U pip && pip install -r requirements.txt; fi",
|
| 42 |
+
},
|
| 43 |
+
"repoguardian": {
|
| 44 |
+
"repo_url": "https://github.com/ruslanmv/RepoGuardian",
|
| 45 |
+
"task_command": "pytest -q || python -m unittest discover || python -m compileall .",
|
| 46 |
+
"setup_script": "if [ -f requirements.txt ]; then python -m venv .venv && . .venv/bin/activate && pip install -U pip && pip install -r requirements.txt; fi",
|
| 47 |
+
},
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
ALLOWED_REPO_PATTERN = re.compile(r"^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/?$")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class RepoTaskRequest(BaseModel):
|
| 54 |
+
environment_id: str = Field(..., min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9._-]+$")
|
| 55 |
+
repo_url: str
|
| 56 |
+
default_branch: str = "main"
|
| 57 |
+
branch: str = "main"
|
| 58 |
+
command: str | None = None
|
| 59 |
+
setup_script: str | None = None
|
| 60 |
+
maintenance_script: str = "echo 'maintenance complete'"
|
| 61 |
+
sandbox_image: str = "matrix-lab-sandbox-python:latest"
|
| 62 |
+
profile: Literal["custom", "gitpilot", "agent-generator", "repoguardian"] = "custom"
|
| 63 |
+
force_rebuild: bool = False
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@app.get("/health")
|
| 67 |
+
async def health():
|
| 68 |
+
runner_status = {"status": "unknown"}
|
| 69 |
+
try:
|
| 70 |
+
runner_status = runner.health()
|
| 71 |
+
except Exception as e: # best-effort surface
|
| 72 |
+
runner_status = {"status": "unreachable", "error": str(e)}
|
| 73 |
+
|
| 74 |
+
return {
|
| 75 |
+
"status": "ok",
|
| 76 |
+
"version": "1.2.0",
|
| 77 |
+
"runs": len(runs),
|
| 78 |
+
"runner_url": runner.base_url,
|
| 79 |
+
"runner": runner_status,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@app.get("/profiles")
|
| 84 |
+
async def list_profiles():
|
| 85 |
+
return {"profiles": PROFILE_PRESETS}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@app.post("/repo/run")
|
| 89 |
+
async def repo_run(req: RepoTaskRequest):
|
| 90 |
+
if req.profile in PROFILE_PRESETS:
|
| 91 |
+
preset = PROFILE_PRESETS[req.profile]
|
| 92 |
+
repo_url = preset["repo_url"]
|
| 93 |
+
setup_script = req.setup_script or preset["setup_script"]
|
| 94 |
+
command = req.command or preset["task_command"]
|
| 95 |
+
else:
|
| 96 |
+
repo_url = req.repo_url
|
| 97 |
+
setup_script = req.setup_script or "echo 'No setup script configured.'"
|
| 98 |
+
command = req.command or "pytest -q || python -m unittest discover || python -m compileall ."
|
| 99 |
+
|
| 100 |
+
if not ALLOWED_REPO_PATTERN.match(repo_url):
|
| 101 |
+
raise HTTPException(status_code=400, detail="repo_url must be a valid GitHub HTTPS repository URL")
|
| 102 |
+
|
| 103 |
+
env_payload = {
|
| 104 |
+
"environment_id": req.environment_id,
|
| 105 |
+
"repo_url": repo_url,
|
| 106 |
+
"default_branch": req.default_branch,
|
| 107 |
+
"sandbox_image": req.sandbox_image,
|
| 108 |
+
"setup_script": setup_script,
|
| 109 |
+
"maintenance_script": req.maintenance_script,
|
| 110 |
+
"task_command": command,
|
| 111 |
+
"setup_network": "egress",
|
| 112 |
+
"task_network": "none",
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
env = runner.create_or_update_environment(env_payload)
|
| 117 |
+
bootstrap = runner.bootstrap_environment(req.environment_id, force_rebuild=req.force_rebuild)
|
| 118 |
+
task = runner.run_environment_task(
|
| 119 |
+
req.environment_id,
|
| 120 |
+
{
|
| 121 |
+
"branch": req.branch,
|
| 122 |
+
"command": command,
|
| 123 |
+
"task_network": "none",
|
| 124 |
+
},
|
| 125 |
+
)
|
| 126 |
+
except httpx.HTTPStatusError as e:
|
| 127 |
+
detail = e.response.text if e.response is not None else str(e)
|
| 128 |
+
raise HTTPException(status_code=502, detail=f"runner error: {detail}")
|
| 129 |
+
except Exception as e:
|
| 130 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 131 |
+
|
| 132 |
+
return {
|
| 133 |
+
"environment": env,
|
| 134 |
+
"bootstrap": bootstrap,
|
| 135 |
+
"task": task,
|
| 136 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# Existing ZIP verification UI/API --------------------------------------------
|
| 141 |
+
|
| 142 |
+
SPACE_INDEX = BASE_DIR / "static" / "space" / "index.html"
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.get("/", response_class=HTMLResponse)
|
| 146 |
+
async def home():
|
| 147 |
+
"""MatrixLab Space — Matrix CLI Console + sandbox-enable button."""
|
| 148 |
+
return FileResponse(str(SPACE_INDEX), media_type="text/html")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@app.get("/verify", response_class=HTMLResponse)
|
| 152 |
+
async def verify_home(request: Request):
|
| 153 |
+
"""Legacy ZIP-verification UI (moved from /)."""
|
| 154 |
+
recent = sorted(runs.values(), key=lambda r: r.get("created", ""), reverse=True)[:20]
|
| 155 |
+
return templates.TemplateResponse(
|
| 156 |
+
request=request,
|
| 157 |
+
name="home.html",
|
| 158 |
+
context={"request": request, "runs": recent, "runner_url": runner.base_url},
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@app.post("/upload", response_class=HTMLResponse)
|
| 163 |
+
async def upload(request: Request, file: UploadFile = File(...)):
|
| 164 |
+
if not file.filename or not file.filename.endswith(".zip"):
|
| 165 |
+
return templates.TemplateResponse(
|
| 166 |
+
request=request,
|
| 167 |
+
name="home.html",
|
| 168 |
+
context={"request": request, "runs": list(runs.values())[:20], "error": "Please upload a .zip file.", "runner_url": runner.base_url},
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
zip_bytes = await file.read()
|
| 172 |
+
if len(zip_bytes) > 50 * 1024 * 1024:
|
| 173 |
+
return templates.TemplateResponse(
|
| 174 |
+
request=request,
|
| 175 |
+
name="home.html",
|
| 176 |
+
context={"request": request, "runs": list(runs.values())[:20], "error": "File too large. Maximum 50MB.", "runner_url": runner.base_url},
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
run_id = str(uuid.uuid4())[:8]
|
| 180 |
+
result = run_verification(zip_bytes)
|
| 181 |
+
|
| 182 |
+
run_data = {
|
| 183 |
+
"id": run_id,
|
| 184 |
+
"filename": file.filename,
|
| 185 |
+
"status": result.status,
|
| 186 |
+
"language": result.detected_language,
|
| 187 |
+
"framework": result.detected_framework,
|
| 188 |
+
"files_count": result.files_count,
|
| 189 |
+
"steps": [{"name": s.name, "status": s.status, "message": s.message, "logs": s.logs} for s in result.steps],
|
| 190 |
+
"summary": result.summary,
|
| 191 |
+
"created": datetime.now(timezone.utc).isoformat(),
|
| 192 |
+
}
|
| 193 |
+
runs[run_id] = run_data
|
| 194 |
+
|
| 195 |
+
return templates.TemplateResponse(request=request, name="result.html", context={"request": request, "run": run_data})
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@app.post("/runs")
|
| 199 |
+
async def api_create_run(file: UploadFile = File(...)):
|
| 200 |
+
zip_bytes = await file.read()
|
| 201 |
+
if len(zip_bytes) > 50 * 1024 * 1024:
|
| 202 |
+
raise HTTPException(status_code=413, detail="File too large. Maximum 50MB.")
|
| 203 |
+
|
| 204 |
+
run_id = str(uuid.uuid4())[:8]
|
| 205 |
+
result = run_verification(zip_bytes)
|
| 206 |
+
|
| 207 |
+
run_data = {
|
| 208 |
+
"id": run_id,
|
| 209 |
+
"filename": file.filename or "project.zip",
|
| 210 |
+
"status": result.status,
|
| 211 |
+
"language": result.detected_language,
|
| 212 |
+
"framework": result.detected_framework,
|
| 213 |
+
"files_count": result.files_count,
|
| 214 |
+
"steps": [{"name": s.name, "status": s.status, "message": s.message, "logs": s.logs} for s in result.steps],
|
| 215 |
+
"summary": result.summary,
|
| 216 |
+
"created": datetime.now(timezone.utc).isoformat(),
|
| 217 |
+
}
|
| 218 |
+
runs[run_id] = run_data
|
| 219 |
+
return run_data
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@app.get("/runs")
|
| 223 |
+
async def api_list_runs():
|
| 224 |
+
recent = sorted(runs.values(), key=lambda r: r.get("created", ""), reverse=True)[:50]
|
| 225 |
+
return {"runs": recent, "total": len(runs)}
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
@app.get("/runs/{run_id}")
|
| 229 |
+
async def api_get_run(run_id: str):
|
| 230 |
+
if run_id not in runs:
|
| 231 |
+
raise HTTPException(status_code=404, detail="Run not found")
|
| 232 |
+
return runs[run_id]
|
app/mcp_sandbox.py
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face MCP sandbox worker for MatrixHub.
|
| 2 |
+
|
| 3 |
+
This module is the runtime that lives inside the Hugging Face Docker Space.
|
| 4 |
+
MatrixHub should call it through the MatrixHub backend only, never directly from
|
| 5 |
+
browser clients.
|
| 6 |
+
|
| 7 |
+
Scope of this MVP:
|
| 8 |
+
- Start one or a few short-lived stdio MCP server subprocesses.
|
| 9 |
+
- Initialize the MCP server and run tools/list.
|
| 10 |
+
- Allow controlled tool calls during a hard TTL, default 10 minutes.
|
| 11 |
+
- Stream lifecycle events over SSE.
|
| 12 |
+
- Kill the subprocess and delete /tmp state at expiry.
|
| 13 |
+
|
| 14 |
+
Security note: this is a compatibility/demo sandbox for curated MatrixHub
|
| 15 |
+
catalog entries. It is not equivalent to Firecracker, gVisor, Kata, or a
|
| 16 |
+
hardened multi-tenant arbitrary-code execution platform.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import asyncio
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import shlex
|
| 24 |
+
import shutil
|
| 25 |
+
import time
|
| 26 |
+
import uuid
|
| 27 |
+
from dataclasses import dataclass, field
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Any, AsyncIterator, Literal
|
| 30 |
+
|
| 31 |
+
from fastapi import APIRouter, Header, HTTPException, Request
|
| 32 |
+
from fastapi.responses import StreamingResponse
|
| 33 |
+
from pydantic import BaseModel, Field
|
| 34 |
+
|
| 35 |
+
router = APIRouter(prefix="/mcp", tags=["mcp-sandbox"])
|
| 36 |
+
|
| 37 |
+
MAX_TTL_SECONDS = int(os.environ.get("MATRIXLAB_MCP_MAX_TTL_SECONDS", "600"))
|
| 38 |
+
MAX_INSTALL_SECONDS = int(os.environ.get("MATRIXLAB_MCP_INSTALL_TIMEOUT_SECONDS", "120"))
|
| 39 |
+
MAX_STARTUP_SECONDS = int(os.environ.get("MATRIXLAB_MCP_STARTUP_TIMEOUT_SECONDS", "45"))
|
| 40 |
+
MAX_RPC_SECONDS = int(os.environ.get("MATRIXLAB_MCP_RPC_TIMEOUT_SECONDS", "20"))
|
| 41 |
+
MAX_LOG_CHARS = int(os.environ.get("MATRIXLAB_MCP_MAX_LOG_CHARS", "20000"))
|
| 42 |
+
MAX_EVENT_HISTORY = int(os.environ.get("MATRIXLAB_MCP_MAX_EVENT_HISTORY", "200"))
|
| 43 |
+
BASE_WORKDIR = Path(os.environ.get("MATRIXLAB_MCP_WORKDIR", "/tmp/matrixlab-mcp-sessions"))
|
| 44 |
+
SANDBOX_TOKEN = os.environ.get("MATRIXLAB_SANDBOX_TOKEN", "").strip()
|
| 45 |
+
|
| 46 |
+
# Per-session memory budget (MB) used to derive a safe concurrent-session cap
|
| 47 |
+
# from the Hugging Face instance specs when MATRIXLAB_MCP_MAX_SESSIONS=auto.
|
| 48 |
+
MB_PER_SESSION = int(os.environ.get("MATRIXLAB_MCP_MB_PER_SESSION", "700"))
|
| 49 |
+
# Absolute ceiling so a large box can't spawn an unbounded number of procs.
|
| 50 |
+
MAX_SESSIONS_CEILING = int(os.environ.get("MATRIXLAB_MCP_MAX_SESSIONS_CEILING", "32"))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _detect_total_mem_mb() -> int:
|
| 54 |
+
"""Total RAM in MB (cgroup-aware), best-effort. 0 if unknown."""
|
| 55 |
+
# Respect a container memory limit first (HF runs in containers/cgroups).
|
| 56 |
+
for path in (
|
| 57 |
+
"/sys/fs/cgroup/memory.max", # cgroup v2
|
| 58 |
+
"/sys/fs/cgroup/memory/memory.limit_in_bytes", # cgroup v1
|
| 59 |
+
):
|
| 60 |
+
try:
|
| 61 |
+
raw = Path(path).read_text().strip()
|
| 62 |
+
if raw and raw != "max":
|
| 63 |
+
val = int(raw)
|
| 64 |
+
# cgroup v1 reports a huge sentinel when unlimited; ignore it.
|
| 65 |
+
if 0 < val < (1 << 62):
|
| 66 |
+
return val // (1024 * 1024)
|
| 67 |
+
except Exception:
|
| 68 |
+
pass
|
| 69 |
+
try:
|
| 70 |
+
for line in Path("/proc/meminfo").read_text().splitlines():
|
| 71 |
+
if line.startswith("MemTotal:"):
|
| 72 |
+
return int(line.split()[1]) // 1024 # kB -> MB
|
| 73 |
+
except Exception:
|
| 74 |
+
pass
|
| 75 |
+
return 0
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _resolve_max_sessions() -> int:
|
| 79 |
+
"""Resolve the concurrent-session cap.
|
| 80 |
+
|
| 81 |
+
``MATRIXLAB_MCP_MAX_SESSIONS`` may be an integer or ``auto``. ``auto``
|
| 82 |
+
derives a safe cap from the instance specs (CPU count and total RAM)
|
| 83 |
+
so MatrixLab can serve up to ~10 parallel sandboxes on a typical
|
| 84 |
+
HF CPU-upgrade box without overcommitting memory.
|
| 85 |
+
"""
|
| 86 |
+
raw = os.environ.get("MATRIXLAB_MCP_MAX_SESSIONS", "auto").strip().lower()
|
| 87 |
+
if raw not in ("", "auto"):
|
| 88 |
+
try:
|
| 89 |
+
return max(1, min(int(raw), MAX_SESSIONS_CEILING))
|
| 90 |
+
except ValueError:
|
| 91 |
+
pass
|
| 92 |
+
cpu = os.cpu_count() or 2
|
| 93 |
+
mem_mb = _detect_total_mem_mb()
|
| 94 |
+
# Reserve ~1.5 GB for the OS + uvicorn + node/npm caches.
|
| 95 |
+
usable_mb = max(0, mem_mb - 1536) if mem_mb else 0
|
| 96 |
+
by_mem = (usable_mb // MB_PER_SESSION) if usable_mb else cpu * 2
|
| 97 |
+
by_cpu = cpu * 2 # MCP servers are largely I/O-bound during a trial
|
| 98 |
+
derived = min(by_mem, by_cpu) if by_mem else by_cpu
|
| 99 |
+
return max(1, min(derived, MAX_SESSIONS_CEILING))
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
MAX_SESSIONS = _resolve_max_sessions()
|
| 103 |
+
|
| 104 |
+
# Keep this narrow for the free HF MVP. Add more prefixes only after review.
|
| 105 |
+
ALLOWED_BINARIES = {"npx", "uvx", "pipx", "python", "python3", "node"}
|
| 106 |
+
|
| 107 |
+
BLOCKED_TOKENS = {
|
| 108 |
+
"sudo", "docker", "podman", "kubectl", "systemctl", "mount", "umount",
|
| 109 |
+
"mkfs", "apt", "apt-get", "yum", "dnf", "apk", "curl", "wget", "chmod",
|
| 110 |
+
"chown", "ssh", "scp", "bash", "sh", "zsh", "fish", "powershell",
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
BLOCKED_SUBSTRINGS = ["|", ";", "&&", "||", "`", "$(", ">", "<", "\n", "\r", "rm -rf /"]
|
| 114 |
+
ACTIVE_STATUSES = {"created", "installing", "starting", "initializing", "running"}
|
| 115 |
+
TERMINAL_STATUSES = {"expired", "deleted", "failed", "timeout", "install_failed", "start_failed", "mcp_failed"}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class StartSessionRequest(BaseModel):
|
| 119 |
+
entity_id: str = Field(..., min_length=1, max_length=160)
|
| 120 |
+
runtime: Literal["node", "python", "generic"] = "generic"
|
| 121 |
+
start_command: str = Field(..., min_length=2, max_length=1000)
|
| 122 |
+
install_command: str | None = Field(default=None, max_length=1000)
|
| 123 |
+
transport: Literal["stdio"] = "stdio"
|
| 124 |
+
ttl_seconds: int = Field(default=600, ge=30, le=600)
|
| 125 |
+
env: dict[str, str] = Field(default_factory=dict)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class ToolCallRequest(BaseModel):
|
| 129 |
+
name: str = Field(..., min_length=1, max_length=160)
|
| 130 |
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@dataclass
|
| 134 |
+
class Session:
|
| 135 |
+
id: str
|
| 136 |
+
entity_id: str
|
| 137 |
+
workdir: Path
|
| 138 |
+
start_command: str
|
| 139 |
+
install_command: str | None
|
| 140 |
+
status: str = "created"
|
| 141 |
+
created_at: float = field(default_factory=time.time)
|
| 142 |
+
expires_at: float = 0.0
|
| 143 |
+
process: asyncio.subprocess.Process | None = None
|
| 144 |
+
logs: list[str] = field(default_factory=list)
|
| 145 |
+
tools: list[dict[str, Any]] = field(default_factory=list)
|
| 146 |
+
rpc_id: int = 0
|
| 147 |
+
rpc_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
| 148 |
+
stderr_task: asyncio.Task | None = None
|
| 149 |
+
cleanup_task: asyncio.Task | None = None
|
| 150 |
+
events: list[dict[str, Any]] = field(default_factory=list)
|
| 151 |
+
event_queue: asyncio.Queue[dict[str, Any]] = field(default_factory=asyncio.Queue)
|
| 152 |
+
|
| 153 |
+
def emit(self, step: str, status: str, message: str, data: dict[str, Any] | None = None) -> None:
|
| 154 |
+
event = {
|
| 155 |
+
"session_id": self.id,
|
| 156 |
+
"entity_id": self.entity_id,
|
| 157 |
+
"ts": time.time(),
|
| 158 |
+
"step": step,
|
| 159 |
+
"status": status,
|
| 160 |
+
"message": message,
|
| 161 |
+
"data": data or {},
|
| 162 |
+
}
|
| 163 |
+
self.events.append(event)
|
| 164 |
+
if len(self.events) > MAX_EVENT_HISTORY:
|
| 165 |
+
self.events = self.events[-MAX_EVENT_HISTORY:]
|
| 166 |
+
try:
|
| 167 |
+
self.event_queue.put_nowait(event)
|
| 168 |
+
except asyncio.QueueFull:
|
| 169 |
+
pass
|
| 170 |
+
|
| 171 |
+
def add_log(self, text: str) -> None:
|
| 172 |
+
if not text:
|
| 173 |
+
return
|
| 174 |
+
text = text[-MAX_LOG_CHARS:]
|
| 175 |
+
self.logs.append(text)
|
| 176 |
+
joined = "\n".join(self.logs)
|
| 177 |
+
if len(joined) > MAX_LOG_CHARS:
|
| 178 |
+
self.logs = [joined[-MAX_LOG_CHARS:]]
|
| 179 |
+
self.emit("logs", "ok", "log output", {"text": text[-4000:]})
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
sessions: dict[str, Session] = {}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _require_auth(authorization: str | None) -> None:
|
| 186 |
+
if not SANDBOX_TOKEN:
|
| 187 |
+
return
|
| 188 |
+
if authorization != f"Bearer {SANDBOX_TOKEN}":
|
| 189 |
+
raise HTTPException(status_code=401, detail="missing or invalid sandbox token")
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _validate_command(command: str) -> list[str]:
|
| 193 |
+
lowered = command.lower()
|
| 194 |
+
for bad in BLOCKED_SUBSTRINGS:
|
| 195 |
+
if bad in lowered:
|
| 196 |
+
raise HTTPException(status_code=400, detail=f"blocked shell pattern: {bad}")
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
parts = shlex.split(command)
|
| 200 |
+
except ValueError as e:
|
| 201 |
+
raise HTTPException(status_code=400, detail=f"invalid command: {e}") from e
|
| 202 |
+
|
| 203 |
+
if not parts:
|
| 204 |
+
raise HTTPException(status_code=400, detail="empty command")
|
| 205 |
+
|
| 206 |
+
binary = Path(parts[0]).name
|
| 207 |
+
if binary not in ALLOWED_BINARIES:
|
| 208 |
+
raise HTTPException(status_code=400, detail=f"command not allowed: {binary}")
|
| 209 |
+
|
| 210 |
+
for token in parts:
|
| 211 |
+
normalized = Path(token).name.lower()
|
| 212 |
+
if normalized in BLOCKED_TOKENS:
|
| 213 |
+
raise HTTPException(status_code=400, detail=f"blocked token: {token}")
|
| 214 |
+
|
| 215 |
+
return parts
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _public_session(s: Session) -> dict[str, Any]:
|
| 219 |
+
return {
|
| 220 |
+
"session_id": s.id,
|
| 221 |
+
"entity_id": s.entity_id,
|
| 222 |
+
"status": s.status,
|
| 223 |
+
"created_at": s.created_at,
|
| 224 |
+
"expires_at": s.expires_at,
|
| 225 |
+
"ttl_remaining_seconds": max(0, int(s.expires_at - time.time())),
|
| 226 |
+
"tools": s.tools,
|
| 227 |
+
"logs": s.logs[-5:],
|
| 228 |
+
"events": s.events[-20:],
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _sse(event: dict[str, Any], event_name: str = "message") -> str:
|
| 233 |
+
return f"event: {event_name}\ndata: {json.dumps(event, ensure_ascii=False)}\n\n"
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
async def _read_stderr(session: Session) -> None:
|
| 237 |
+
proc = session.process
|
| 238 |
+
if not proc or not proc.stderr:
|
| 239 |
+
return
|
| 240 |
+
while True:
|
| 241 |
+
line = await proc.stderr.readline()
|
| 242 |
+
if not line:
|
| 243 |
+
return
|
| 244 |
+
session.add_log("[stderr] " + line.decode(errors="replace").rstrip())
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
async def _send_json(proc: asyncio.subprocess.Process, msg: dict[str, Any]) -> None:
|
| 248 |
+
if proc.stdin is None:
|
| 249 |
+
raise RuntimeError("MCP process stdin is closed")
|
| 250 |
+
proc.stdin.write((json.dumps(msg) + "\n").encode())
|
| 251 |
+
await proc.stdin.drain()
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
async def _recv_json(proc: asyncio.subprocess.Process, timeout_s: int = MAX_RPC_SECONDS) -> dict[str, Any]:
|
| 255 |
+
if proc.stdout is None:
|
| 256 |
+
raise RuntimeError("MCP process stdout is closed")
|
| 257 |
+
|
| 258 |
+
async def _read_loop() -> dict[str, Any]:
|
| 259 |
+
while True:
|
| 260 |
+
line = await proc.stdout.readline()
|
| 261 |
+
if not line:
|
| 262 |
+
raise RuntimeError("MCP process exited before response")
|
| 263 |
+
raw = line.decode(errors="replace").strip()
|
| 264 |
+
if not raw:
|
| 265 |
+
continue
|
| 266 |
+
try:
|
| 267 |
+
return json.loads(raw)
|
| 268 |
+
except json.JSONDecodeError:
|
| 269 |
+
# Some MCP servers print startup logs to stdout; retain and ignore.
|
| 270 |
+
continue
|
| 271 |
+
|
| 272 |
+
return await asyncio.wait_for(_read_loop(), timeout=timeout_s)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
async def _rpc(session: Session, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 276 |
+
proc = session.process
|
| 277 |
+
if not proc or proc.returncode is not None:
|
| 278 |
+
raise HTTPException(status_code=409, detail="MCP session is not running")
|
| 279 |
+
|
| 280 |
+
async with session.rpc_lock:
|
| 281 |
+
session.rpc_id += 1
|
| 282 |
+
request_id = session.rpc_id
|
| 283 |
+
await _send_json(proc, {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}})
|
| 284 |
+
while True:
|
| 285 |
+
resp = await _recv_json(proc)
|
| 286 |
+
# Ignore notifications and unrelated ids.
|
| 287 |
+
if resp.get("id") != request_id:
|
| 288 |
+
continue
|
| 289 |
+
if "error" in resp:
|
| 290 |
+
raise HTTPException(status_code=502, detail=resp["error"])
|
| 291 |
+
return resp
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
async def _initialize_and_list_tools(session: Session) -> None:
|
| 295 |
+
proc = session.process
|
| 296 |
+
if not proc:
|
| 297 |
+
raise RuntimeError("process not started")
|
| 298 |
+
|
| 299 |
+
async with session.rpc_lock:
|
| 300 |
+
session.rpc_id += 1
|
| 301 |
+
init_id = session.rpc_id
|
| 302 |
+
session.emit("mcp_initialize", "start", "Initializing MCP server")
|
| 303 |
+
await _send_json(
|
| 304 |
+
proc,
|
| 305 |
+
{
|
| 306 |
+
"jsonrpc": "2.0",
|
| 307 |
+
"id": init_id,
|
| 308 |
+
"method": "initialize",
|
| 309 |
+
"params": {
|
| 310 |
+
"protocolVersion": "2024-11-05",
|
| 311 |
+
"capabilities": {},
|
| 312 |
+
"clientInfo": {"name": "matrixhub-hf-sandbox", "version": "0.2.0"},
|
| 313 |
+
},
|
| 314 |
+
},
|
| 315 |
+
)
|
| 316 |
+
while True:
|
| 317 |
+
init_resp = await _recv_json(proc, timeout_s=MAX_RPC_SECONDS)
|
| 318 |
+
if init_resp.get("id") != init_id:
|
| 319 |
+
continue
|
| 320 |
+
break
|
| 321 |
+
if "error" in init_resp:
|
| 322 |
+
raise RuntimeError(f"MCP initialize failed: {init_resp['error']}")
|
| 323 |
+
session.emit("mcp_initialize", "ok", "MCP initialize succeeded")
|
| 324 |
+
|
| 325 |
+
await _send_json(proc, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
|
| 326 |
+
|
| 327 |
+
session.rpc_id += 1
|
| 328 |
+
tools_id = session.rpc_id
|
| 329 |
+
session.emit("tools_list", "start", "Listing MCP tools")
|
| 330 |
+
await _send_json(proc, {"jsonrpc": "2.0", "id": tools_id, "method": "tools/list", "params": {}})
|
| 331 |
+
while True:
|
| 332 |
+
tools_resp = await _recv_json(proc, timeout_s=MAX_RPC_SECONDS)
|
| 333 |
+
if tools_resp.get("id") != tools_id:
|
| 334 |
+
continue
|
| 335 |
+
break
|
| 336 |
+
if "error" in tools_resp:
|
| 337 |
+
raise RuntimeError(f"MCP tools/list failed: {tools_resp['error']}")
|
| 338 |
+
|
| 339 |
+
result = tools_resp.get("result") or {}
|
| 340 |
+
tools = result.get("tools") or []
|
| 341 |
+
session.tools = tools if isinstance(tools, list) else []
|
| 342 |
+
session.emit("tools_list", "ok", f"Found {len(session.tools)} tools", {"tools_count": len(session.tools)})
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
async def _run_session(session_id: str, req: StartSessionRequest) -> None:
|
| 346 |
+
session = sessions[session_id]
|
| 347 |
+
session.status = "installing" if req.install_command else "starting"
|
| 348 |
+
|
| 349 |
+
try:
|
| 350 |
+
env = os.environ.copy()
|
| 351 |
+
# Do not pass arbitrary secrets. Only explicit non-sensitive public env.
|
| 352 |
+
for key, value in req.env.items():
|
| 353 |
+
if not key.startswith("MATRIXHUB_PUBLIC_"):
|
| 354 |
+
raise RuntimeError(f"env key not allowed: {key}")
|
| 355 |
+
env[key] = value
|
| 356 |
+
|
| 357 |
+
if req.install_command:
|
| 358 |
+
session.emit("install", "start", "Installing MCP server")
|
| 359 |
+
install_parts = _validate_command(req.install_command)
|
| 360 |
+
install_proc = await asyncio.create_subprocess_exec(
|
| 361 |
+
*install_parts,
|
| 362 |
+
cwd=session.workdir,
|
| 363 |
+
stdout=asyncio.subprocess.PIPE,
|
| 364 |
+
stderr=asyncio.subprocess.STDOUT,
|
| 365 |
+
env=env,
|
| 366 |
+
)
|
| 367 |
+
output, _ = await asyncio.wait_for(install_proc.communicate(), timeout=MAX_INSTALL_SECONDS)
|
| 368 |
+
session.add_log(output.decode(errors="replace"))
|
| 369 |
+
if install_proc.returncode != 0:
|
| 370 |
+
session.status = "install_failed"
|
| 371 |
+
session.emit("install", "error", "Install failed", {"returncode": install_proc.returncode})
|
| 372 |
+
await cleanup_session(session_id, final_status="install_failed")
|
| 373 |
+
return
|
| 374 |
+
session.emit("install", "ok", "Install completed")
|
| 375 |
+
|
| 376 |
+
session.status = "starting"
|
| 377 |
+
session.emit("start", "start", "Starting MCP server")
|
| 378 |
+
start_parts = _validate_command(req.start_command)
|
| 379 |
+
proc = await asyncio.create_subprocess_exec(
|
| 380 |
+
*start_parts,
|
| 381 |
+
cwd=session.workdir,
|
| 382 |
+
stdin=asyncio.subprocess.PIPE,
|
| 383 |
+
stdout=asyncio.subprocess.PIPE,
|
| 384 |
+
stderr=asyncio.subprocess.PIPE,
|
| 385 |
+
env=env,
|
| 386 |
+
)
|
| 387 |
+
session.process = proc
|
| 388 |
+
session.stderr_task = asyncio.create_task(_read_stderr(session))
|
| 389 |
+
session.emit("start", "ok", "MCP process started", {"pid": getattr(proc, "pid", None)})
|
| 390 |
+
|
| 391 |
+
session.status = "initializing"
|
| 392 |
+
await asyncio.wait_for(_initialize_and_list_tools(session), timeout=MAX_STARTUP_SECONDS)
|
| 393 |
+
session.status = "running"
|
| 394 |
+
session.emit("ready", "ok", "Sandbox ready for testing", {"ttl_remaining_seconds": max(0, int(session.expires_at - time.time()))})
|
| 395 |
+
|
| 396 |
+
except asyncio.TimeoutError:
|
| 397 |
+
session.status = "timeout"
|
| 398 |
+
session.add_log("session timed out during install/startup")
|
| 399 |
+
session.emit("timeout", "error", "Session timed out during install/startup")
|
| 400 |
+
await cleanup_session(session_id, final_status="timeout")
|
| 401 |
+
except Exception as e:
|
| 402 |
+
session.status = "failed"
|
| 403 |
+
session.add_log(str(e))
|
| 404 |
+
session.emit("failed", "error", str(e))
|
| 405 |
+
await cleanup_session(session_id, final_status="failed")
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
async def cleanup_session(session_id: str, final_status: str = "expired") -> None:
|
| 409 |
+
session = sessions.get(session_id)
|
| 410 |
+
if not session:
|
| 411 |
+
return
|
| 412 |
+
|
| 413 |
+
if session.status in TERMINAL_STATUSES and final_status == "expired":
|
| 414 |
+
return
|
| 415 |
+
|
| 416 |
+
proc = session.process
|
| 417 |
+
if proc and proc.returncode is None:
|
| 418 |
+
try:
|
| 419 |
+
proc.terminate()
|
| 420 |
+
await asyncio.wait_for(proc.wait(), timeout=3)
|
| 421 |
+
except Exception:
|
| 422 |
+
try:
|
| 423 |
+
proc.kill()
|
| 424 |
+
await proc.wait()
|
| 425 |
+
except Exception:
|
| 426 |
+
pass
|
| 427 |
+
|
| 428 |
+
if session.stderr_task:
|
| 429 |
+
session.stderr_task.cancel()
|
| 430 |
+
|
| 431 |
+
shutil.rmtree(session.workdir, ignore_errors=True)
|
| 432 |
+
session.status = final_status
|
| 433 |
+
session.emit("cleanup", "ok" if final_status in {"expired", "deleted"} else "error", f"Session {final_status}")
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
async def _expire_later(session_id: str, ttl_seconds: int) -> None:
|
| 437 |
+
await asyncio.sleep(ttl_seconds)
|
| 438 |
+
await cleanup_session(session_id, final_status="expired")
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
@router.get("/health")
|
| 442 |
+
async def mcp_health():
|
| 443 |
+
active = len([s for s in sessions.values() if s.status in ACTIVE_STATUSES])
|
| 444 |
+
return {
|
| 445 |
+
"status": "ok",
|
| 446 |
+
"active_sessions": active,
|
| 447 |
+
"max_sessions": MAX_SESSIONS,
|
| 448 |
+
"available_slots": max(0, MAX_SESSIONS - active),
|
| 449 |
+
"max_ttl_seconds": MAX_TTL_SECONDS,
|
| 450 |
+
"instance": {
|
| 451 |
+
"cpu": os.cpu_count(),
|
| 452 |
+
"total_mem_mb": _detect_total_mem_mb(),
|
| 453 |
+
"mb_per_session": MB_PER_SESSION,
|
| 454 |
+
},
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
@router.post("/sessions")
|
| 459 |
+
async def start_session(req: StartSessionRequest, authorization: str | None = Header(default=None)):
|
| 460 |
+
_require_auth(authorization)
|
| 461 |
+
|
| 462 |
+
active = [s for s in sessions.values() if s.status in ACTIVE_STATUSES]
|
| 463 |
+
if len(active) >= MAX_SESSIONS:
|
| 464 |
+
raise HTTPException(status_code=429, detail="no sandbox capacity available")
|
| 465 |
+
|
| 466 |
+
if req.ttl_seconds > MAX_TTL_SECONDS:
|
| 467 |
+
raise HTTPException(status_code=400, detail=f"max ttl is {MAX_TTL_SECONDS} seconds")
|
| 468 |
+
|
| 469 |
+
if req.install_command:
|
| 470 |
+
_validate_command(req.install_command)
|
| 471 |
+
_validate_command(req.start_command)
|
| 472 |
+
|
| 473 |
+
BASE_WORKDIR.mkdir(parents=True, exist_ok=True)
|
| 474 |
+
session_id = "mcp_" + uuid.uuid4().hex[:16]
|
| 475 |
+
workdir = BASE_WORKDIR / session_id
|
| 476 |
+
workdir.mkdir(parents=True, exist_ok=False)
|
| 477 |
+
|
| 478 |
+
session = Session(
|
| 479 |
+
id=session_id,
|
| 480 |
+
entity_id=req.entity_id,
|
| 481 |
+
workdir=workdir,
|
| 482 |
+
start_command=req.start_command,
|
| 483 |
+
install_command=req.install_command,
|
| 484 |
+
expires_at=time.time() + req.ttl_seconds,
|
| 485 |
+
)
|
| 486 |
+
sessions[session_id] = session
|
| 487 |
+
session.emit("resolve", "ok", f"Accepted sandbox request for {req.entity_id}")
|
| 488 |
+
|
| 489 |
+
asyncio.create_task(_run_session(session_id, req))
|
| 490 |
+
session.cleanup_task = asyncio.create_task(_expire_later(session_id, req.ttl_seconds))
|
| 491 |
+
|
| 492 |
+
return _public_session(session)
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
@router.get("/sessions")
|
| 496 |
+
async def list_sessions(authorization: str | None = Header(default=None)):
|
| 497 |
+
_require_auth(authorization)
|
| 498 |
+
return {"sessions": [_public_session(s) for s in sessions.values()]}
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
@router.get("/sessions/{session_id}")
|
| 502 |
+
async def get_session(session_id: str, authorization: str | None = Header(default=None)):
|
| 503 |
+
_require_auth(authorization)
|
| 504 |
+
session = sessions.get(session_id)
|
| 505 |
+
if not session:
|
| 506 |
+
raise HTTPException(status_code=404, detail="session not found")
|
| 507 |
+
return _public_session(session)
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
@router.get("/sessions/{session_id}/events")
|
| 511 |
+
async def stream_events(session_id: str, request: Request, authorization: str | None = Header(default=None)):
|
| 512 |
+
_require_auth(authorization)
|
| 513 |
+
session = sessions.get(session_id)
|
| 514 |
+
if not session:
|
| 515 |
+
raise HTTPException(status_code=404, detail="session not found")
|
| 516 |
+
|
| 517 |
+
async def event_generator() -> AsyncIterator[str]:
|
| 518 |
+
for event in session.events:
|
| 519 |
+
yield _sse(event)
|
| 520 |
+
while True:
|
| 521 |
+
if await request.is_disconnected():
|
| 522 |
+
break
|
| 523 |
+
if session.status in TERMINAL_STATUSES and session.event_queue.empty():
|
| 524 |
+
yield _sse({"session_id": session.id, "step": "closed", "status": "ok", "message": session.status, "data": {}}, "done")
|
| 525 |
+
break
|
| 526 |
+
try:
|
| 527 |
+
event = await asyncio.wait_for(session.event_queue.get(), timeout=15)
|
| 528 |
+
yield _sse(event)
|
| 529 |
+
except asyncio.TimeoutError:
|
| 530 |
+
yield ": keep-alive\n\n"
|
| 531 |
+
|
| 532 |
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
@router.get("/sessions/{session_id}/tools")
|
| 536 |
+
async def list_tools(session_id: str, authorization: str | None = Header(default=None)):
|
| 537 |
+
_require_auth(authorization)
|
| 538 |
+
session = sessions.get(session_id)
|
| 539 |
+
if not session:
|
| 540 |
+
raise HTTPException(status_code=404, detail="session not found")
|
| 541 |
+
if session.status != "running":
|
| 542 |
+
raise HTTPException(status_code=409, detail=f"session is {session.status}")
|
| 543 |
+
return {"tools": session.tools}
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
@router.post("/sessions/{session_id}/tools/call")
|
| 547 |
+
async def call_tool(session_id: str, req: ToolCallRequest, authorization: str | None = Header(default=None)):
|
| 548 |
+
_require_auth(authorization)
|
| 549 |
+
session = sessions.get(session_id)
|
| 550 |
+
if not session:
|
| 551 |
+
raise HTTPException(status_code=404, detail="session not found")
|
| 552 |
+
if session.status != "running":
|
| 553 |
+
raise HTTPException(status_code=409, detail=f"session is {session.status}")
|
| 554 |
+
|
| 555 |
+
session.emit("tool_call", "start", f"Calling tool {req.name}", {"name": req.name})
|
| 556 |
+
response = await _rpc(session, "tools/call", {"name": req.name, "arguments": req.arguments})
|
| 557 |
+
session.emit("tool_call", "ok", f"Tool {req.name} completed", {"name": req.name})
|
| 558 |
+
return response.get("result", response)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
@router.delete("/sessions/{session_id}")
|
| 562 |
+
async def delete_session(session_id: str, authorization: str | None = Header(default=None)):
|
| 563 |
+
_require_auth(authorization)
|
| 564 |
+
if session_id not in sessions:
|
| 565 |
+
raise HTTPException(status_code=404, detail="session not found")
|
| 566 |
+
await cleanup_session(session_id, final_status="deleted")
|
| 567 |
+
return {"ok": True, "session_id": session_id, "status": "deleted"}
|
app/runner_client.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from typing import Any, Dict
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class RunnerClient:
|
| 10 |
+
def __init__(self) -> None:
|
| 11 |
+
self.base_url = os.environ.get("MATRIXLAB_RUNNER_URL", "http://localhost:8000").rstrip("/")
|
| 12 |
+
timeout_s = float(os.environ.get("MATRIXLAB_RUNNER_TIMEOUT_S", "120"))
|
| 13 |
+
self.client = httpx.Client(base_url=self.base_url, timeout=httpx.Timeout(timeout_s))
|
| 14 |
+
|
| 15 |
+
def health(self) -> Dict[str, Any]:
|
| 16 |
+
res = self.client.get("/health")
|
| 17 |
+
res.raise_for_status()
|
| 18 |
+
return res.json()
|
| 19 |
+
|
| 20 |
+
def create_or_update_environment(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 21 |
+
res = self.client.post("/environments", json=payload)
|
| 22 |
+
res.raise_for_status()
|
| 23 |
+
return res.json()
|
| 24 |
+
|
| 25 |
+
def bootstrap_environment(self, environment_id: str, force_rebuild: bool = False) -> Dict[str, Any]:
|
| 26 |
+
res = self.client.post(
|
| 27 |
+
f"/environments/{environment_id}/bootstrap",
|
| 28 |
+
json={"force_rebuild": force_rebuild},
|
| 29 |
+
)
|
| 30 |
+
res.raise_for_status()
|
| 31 |
+
return res.json()
|
| 32 |
+
|
| 33 |
+
def run_environment_task(self, environment_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 34 |
+
res = self.client.post(f"/environments/{environment_id}/run-task", json=payload)
|
| 35 |
+
res.raise_for_status()
|
| 36 |
+
return res.json()
|
app/sandbox.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MatrixLab Sandbox Runner — executes and verifies project artifacts safely.
|
| 3 |
+
|
| 4 |
+
Runs in isolated temp directories with subprocess timeouts.
|
| 5 |
+
No Docker required (HF Spaces compatible).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import ast
|
| 10 |
+
import os
|
| 11 |
+
import shutil
|
| 12 |
+
import subprocess
|
| 13 |
+
import tempfile
|
| 14 |
+
import zipfile
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Literal
|
| 18 |
+
|
| 19 |
+
import yaml
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class StepResult:
|
| 24 |
+
name: str
|
| 25 |
+
status: Literal["success", "warning", "error", "skipped"]
|
| 26 |
+
message: str = ""
|
| 27 |
+
logs: str = ""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class RunResult:
|
| 32 |
+
status: Literal["success", "warning", "error"]
|
| 33 |
+
steps: list[StepResult] = field(default_factory=list)
|
| 34 |
+
detected_language: str = ""
|
| 35 |
+
detected_framework: str = ""
|
| 36 |
+
files_count: int = 0
|
| 37 |
+
summary: str = ""
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def run_verification(zip_bytes: bytes, timeout: int = 120) -> RunResult:
|
| 41 |
+
"""Unpack a ZIP and run a multi-stage verification pipeline."""
|
| 42 |
+
steps: list[StepResult] = []
|
| 43 |
+
work_dir = tempfile.mkdtemp(prefix="matrixlab_")
|
| 44 |
+
|
| 45 |
+
try:
|
| 46 |
+
# Stage 1: Unpack
|
| 47 |
+
step = _unpack(zip_bytes, work_dir)
|
| 48 |
+
steps.append(step)
|
| 49 |
+
if step.status == "error":
|
| 50 |
+
return RunResult(status="error", steps=steps, summary="Failed to unpack ZIP.")
|
| 51 |
+
|
| 52 |
+
# Find the project root (may be inside a subfolder)
|
| 53 |
+
project_dir = _find_project_root(work_dir)
|
| 54 |
+
files = list(Path(project_dir).rglob("*"))
|
| 55 |
+
file_count = len([f for f in files if f.is_file()])
|
| 56 |
+
|
| 57 |
+
# Stage 2: Detect language/framework
|
| 58 |
+
lang, framework = _detect(project_dir)
|
| 59 |
+
steps.append(StepResult(
|
| 60 |
+
name="detect",
|
| 61 |
+
status="success",
|
| 62 |
+
message=f"Language: {lang}, Framework: {framework}",
|
| 63 |
+
))
|
| 64 |
+
|
| 65 |
+
# Stage 3: Validate syntax
|
| 66 |
+
step = _validate_syntax(project_dir)
|
| 67 |
+
steps.append(step)
|
| 68 |
+
|
| 69 |
+
# Stage 4: Security scan
|
| 70 |
+
step = _security_scan(project_dir)
|
| 71 |
+
steps.append(step)
|
| 72 |
+
|
| 73 |
+
# Stage 5: Dependency check
|
| 74 |
+
step = _check_dependencies(project_dir)
|
| 75 |
+
steps.append(step)
|
| 76 |
+
|
| 77 |
+
# Stage 6: Import test (Python only)
|
| 78 |
+
if lang == "python":
|
| 79 |
+
step = _import_test(project_dir, timeout=min(timeout, 30))
|
| 80 |
+
steps.append(step)
|
| 81 |
+
|
| 82 |
+
# Stage 7: Run tests if present
|
| 83 |
+
step = _run_tests(project_dir, timeout=min(timeout, 60))
|
| 84 |
+
steps.append(step)
|
| 85 |
+
|
| 86 |
+
# Determine overall status
|
| 87 |
+
has_errors = any(s.status == "error" for s in steps)
|
| 88 |
+
has_warnings = any(s.status == "warning" for s in steps)
|
| 89 |
+
overall = "error" if has_errors else ("warning" if has_warnings else "success")
|
| 90 |
+
|
| 91 |
+
passed = len([s for s in steps if s.status == "success"])
|
| 92 |
+
total = len([s for s in steps if s.status != "skipped"])
|
| 93 |
+
summary = f"{passed}/{total} checks passed. Language: {lang}, Framework: {framework}."
|
| 94 |
+
|
| 95 |
+
return RunResult(
|
| 96 |
+
status=overall,
|
| 97 |
+
steps=steps,
|
| 98 |
+
detected_language=lang,
|
| 99 |
+
detected_framework=framework,
|
| 100 |
+
files_count=file_count,
|
| 101 |
+
summary=summary,
|
| 102 |
+
)
|
| 103 |
+
finally:
|
| 104 |
+
shutil.rmtree(work_dir, ignore_errors=True)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _unpack(zip_bytes: bytes, dest: str) -> StepResult:
|
| 108 |
+
try:
|
| 109 |
+
zip_path = os.path.join(dest, "project.zip")
|
| 110 |
+
with open(zip_path, "wb") as f:
|
| 111 |
+
f.write(zip_bytes)
|
| 112 |
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
| 113 |
+
zf.extractall(dest)
|
| 114 |
+
os.remove(zip_path)
|
| 115 |
+
return StepResult(name="unpack", status="success", message="ZIP extracted successfully.")
|
| 116 |
+
except Exception as e:
|
| 117 |
+
return StepResult(name="unpack", status="error", message=str(e)[:200])
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _find_project_root(base: str) -> str:
|
| 121 |
+
"""Find the actual project root inside the unpacked directory."""
|
| 122 |
+
entries = os.listdir(base)
|
| 123 |
+
non_hidden = [e for e in entries if not e.startswith(".") and e != "__MACOSX"]
|
| 124 |
+
if len(non_hidden) == 1:
|
| 125 |
+
candidate = os.path.join(base, non_hidden[0])
|
| 126 |
+
if os.path.isdir(candidate):
|
| 127 |
+
return candidate
|
| 128 |
+
return base
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _detect(project_dir: str) -> tuple[str, str]:
|
| 132 |
+
"""Detect language and framework from project files."""
|
| 133 |
+
files = {f.name for f in Path(project_dir).rglob("*") if f.is_file()}
|
| 134 |
+
all_content = ""
|
| 135 |
+
for f in Path(project_dir).rglob("*.py"):
|
| 136 |
+
try:
|
| 137 |
+
all_content += f.read_text(errors="ignore")
|
| 138 |
+
except Exception:
|
| 139 |
+
pass
|
| 140 |
+
|
| 141 |
+
lang = "unknown"
|
| 142 |
+
framework = "unknown"
|
| 143 |
+
|
| 144 |
+
if any(f.endswith(".py") for f in files):
|
| 145 |
+
lang = "python"
|
| 146 |
+
elif any(f.endswith(".js") or f.endswith(".ts") for f in files):
|
| 147 |
+
lang = "javascript"
|
| 148 |
+
elif any(f.endswith(".go") for f in files):
|
| 149 |
+
lang = "go"
|
| 150 |
+
|
| 151 |
+
if "crewai" in all_content.lower() or "agents.yaml" in files:
|
| 152 |
+
framework = "crewai"
|
| 153 |
+
elif "langgraph" in all_content.lower() or "StateGraph" in all_content:
|
| 154 |
+
framework = "langgraph"
|
| 155 |
+
elif "react_loop" in all_content or "TOOLS" in all_content:
|
| 156 |
+
framework = "react"
|
| 157 |
+
elif "watsonx" in all_content.lower() or "agent.yaml" in files:
|
| 158 |
+
framework = "watsonx_orchestrate"
|
| 159 |
+
elif "Flow" in all_content and "crewai" in all_content.lower():
|
| 160 |
+
framework = "crewai_flow"
|
| 161 |
+
|
| 162 |
+
return lang, framework
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _validate_syntax(project_dir: str) -> StepResult:
|
| 166 |
+
"""Check Python syntax and YAML validity."""
|
| 167 |
+
errors = []
|
| 168 |
+
checked = 0
|
| 169 |
+
|
| 170 |
+
for f in Path(project_dir).rglob("*.py"):
|
| 171 |
+
checked += 1
|
| 172 |
+
try:
|
| 173 |
+
ast.parse(f.read_text(errors="ignore"), filename=str(f))
|
| 174 |
+
except SyntaxError as e:
|
| 175 |
+
errors.append(f"{f.name}:{e.lineno}: {e.msg}")
|
| 176 |
+
|
| 177 |
+
for f in Path(project_dir).rglob("*.yaml"):
|
| 178 |
+
checked += 1
|
| 179 |
+
try:
|
| 180 |
+
yaml.safe_load(f.read_text(errors="ignore"))
|
| 181 |
+
except yaml.YAMLError as e:
|
| 182 |
+
errors.append(f"{f.name}: {str(e)[:80]}")
|
| 183 |
+
|
| 184 |
+
for f in Path(project_dir).rglob("*.yml"):
|
| 185 |
+
checked += 1
|
| 186 |
+
try:
|
| 187 |
+
yaml.safe_load(f.read_text(errors="ignore"))
|
| 188 |
+
except yaml.YAMLError as e:
|
| 189 |
+
errors.append(f"{f.name}: {str(e)[:80]}")
|
| 190 |
+
|
| 191 |
+
if errors:
|
| 192 |
+
return StepResult(
|
| 193 |
+
name="syntax",
|
| 194 |
+
status="error",
|
| 195 |
+
message=f"{len(errors)} syntax error(s)",
|
| 196 |
+
logs="\n".join(errors),
|
| 197 |
+
)
|
| 198 |
+
return StepResult(name="syntax", status="success", message=f"{checked} files checked, all valid.")
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _security_scan(project_dir: str) -> StepResult:
|
| 202 |
+
"""AST-based security scan for dangerous patterns."""
|
| 203 |
+
issues = []
|
| 204 |
+
|
| 205 |
+
forbidden_calls = {"eval", "exec", "__import__"}
|
| 206 |
+
forbidden_attrs = {("os", "system"), ("subprocess", "Popen"), ("subprocess", "call")}
|
| 207 |
+
|
| 208 |
+
for f in Path(project_dir).rglob("*.py"):
|
| 209 |
+
try:
|
| 210 |
+
tree = ast.parse(f.read_text(errors="ignore"))
|
| 211 |
+
except SyntaxError:
|
| 212 |
+
continue
|
| 213 |
+
|
| 214 |
+
for node in ast.walk(tree):
|
| 215 |
+
if isinstance(node, ast.Call):
|
| 216 |
+
if isinstance(node.func, ast.Name) and node.func.id in forbidden_calls:
|
| 217 |
+
issues.append(f"{f.name}: {node.func.id}() at line {node.lineno}")
|
| 218 |
+
if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
|
| 219 |
+
key = (node.func.value.id, node.func.attr)
|
| 220 |
+
if key in forbidden_attrs:
|
| 221 |
+
issues.append(f"{f.name}: {key[0]}.{key[1]}() at line {node.lineno}")
|
| 222 |
+
|
| 223 |
+
if issues:
|
| 224 |
+
return StepResult(
|
| 225 |
+
name="security",
|
| 226 |
+
status="error",
|
| 227 |
+
message=f"{len(issues)} security issue(s)",
|
| 228 |
+
logs="\n".join(issues),
|
| 229 |
+
)
|
| 230 |
+
return StepResult(name="security", status="success", message="No dangerous patterns found.")
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _check_dependencies(project_dir: str) -> StepResult:
|
| 234 |
+
"""Check if requirements.txt or pyproject.toml exists."""
|
| 235 |
+
has_requirements = (Path(project_dir) / "requirements.txt").exists()
|
| 236 |
+
has_pyproject = (Path(project_dir) / "pyproject.toml").exists()
|
| 237 |
+
|
| 238 |
+
if has_requirements or has_pyproject:
|
| 239 |
+
deps = []
|
| 240 |
+
if has_requirements:
|
| 241 |
+
content = (Path(project_dir) / "requirements.txt").read_text(errors="ignore")
|
| 242 |
+
deps = [l.strip() for l in content.splitlines() if l.strip() and not l.startswith("#")]
|
| 243 |
+
return StepResult(
|
| 244 |
+
name="dependencies",
|
| 245 |
+
status="success",
|
| 246 |
+
message=f"Found {len(deps)} dependencies.",
|
| 247 |
+
)
|
| 248 |
+
return StepResult(
|
| 249 |
+
name="dependencies",
|
| 250 |
+
status="warning",
|
| 251 |
+
message="No requirements.txt or pyproject.toml found.",
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _import_test(project_dir: str, timeout: int = 30) -> StepResult:
|
| 256 |
+
"""Try to import Python files to check for missing modules."""
|
| 257 |
+
py_files = list(Path(project_dir).rglob("*.py"))
|
| 258 |
+
if not py_files:
|
| 259 |
+
return StepResult(name="import_test", status="skipped", message="No Python files.")
|
| 260 |
+
|
| 261 |
+
# Find the main entry point
|
| 262 |
+
main_file = None
|
| 263 |
+
for candidate in ["main.py", "src/main.py", "app.py"]:
|
| 264 |
+
full = Path(project_dir) / candidate
|
| 265 |
+
if full.exists():
|
| 266 |
+
main_file = full
|
| 267 |
+
break
|
| 268 |
+
if not main_file:
|
| 269 |
+
for f in py_files:
|
| 270 |
+
if f.name == "main.py":
|
| 271 |
+
main_file = f
|
| 272 |
+
break
|
| 273 |
+
if not main_file:
|
| 274 |
+
main_file = py_files[0]
|
| 275 |
+
|
| 276 |
+
try:
|
| 277 |
+
result = subprocess.run(
|
| 278 |
+
["python", "-c", f"import ast; ast.parse(open('{main_file}').read())"],
|
| 279 |
+
capture_output=True, text=True, timeout=timeout,
|
| 280 |
+
cwd=project_dir,
|
| 281 |
+
)
|
| 282 |
+
if result.returncode == 0:
|
| 283 |
+
return StepResult(name="import_test", status="success", message=f"Parsed {main_file.name} successfully.")
|
| 284 |
+
return StepResult(
|
| 285 |
+
name="import_test", status="warning",
|
| 286 |
+
message=f"Parse check had issues.", logs=result.stderr[:500],
|
| 287 |
+
)
|
| 288 |
+
except subprocess.TimeoutExpired:
|
| 289 |
+
return StepResult(name="import_test", status="warning", message="Import test timed out.")
|
| 290 |
+
except Exception as e:
|
| 291 |
+
return StepResult(name="import_test", status="warning", message=str(e)[:200])
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _run_tests(project_dir: str, timeout: int = 60) -> StepResult:
|
| 295 |
+
"""Run pytest if test files exist."""
|
| 296 |
+
test_files = list(Path(project_dir).rglob("test_*.py"))
|
| 297 |
+
if not test_files:
|
| 298 |
+
return StepResult(name="tests", status="skipped", message="No test files found.")
|
| 299 |
+
|
| 300 |
+
try:
|
| 301 |
+
result = subprocess.run(
|
| 302 |
+
["python", "-m", "pytest", "--tb=short", "-q"] + [str(f) for f in test_files],
|
| 303 |
+
capture_output=True, text=True, timeout=timeout,
|
| 304 |
+
cwd=project_dir,
|
| 305 |
+
env={**os.environ, "PYTHONPATH": project_dir},
|
| 306 |
+
)
|
| 307 |
+
if result.returncode == 0:
|
| 308 |
+
return StepResult(name="tests", status="success", message="Tests passed.", logs=result.stdout[:1000])
|
| 309 |
+
return StepResult(
|
| 310 |
+
name="tests", status="error",
|
| 311 |
+
message="Tests failed.", logs=(result.stdout + result.stderr)[:1000],
|
| 312 |
+
)
|
| 313 |
+
except subprocess.TimeoutExpired:
|
| 314 |
+
return StepResult(name="tests", status="warning", message="Tests timed out.")
|
| 315 |
+
except Exception as e:
|
| 316 |
+
return StepResult(name="tests", status="warning", message=str(e)[:200])
|
app/static/.gitkeep
ADDED
|
File without changes
|
app/static/space/data.jsx
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
data.jsx — catalog data + CLI response engine
|
| 3 |
+
Exports to window.
|
| 4 |
+
============================================================ */
|
| 5 |
+
|
| 6 |
+
const TOOLS = [
|
| 7 |
+
{ id: "retell-ai", initials: "RA", name: "Retell AI", type: "Voice Agent",
|
| 8 |
+
description: "Deploy real-time conversational AI agents for calls, support, and workflow automation.",
|
| 9 |
+
installs: "1.6K", rating: "3.5", updated: "2 days ago", verified: true, kind: "Tool",
|
| 10 |
+
tags: ["voice", "agent", "support", "calls", "automation"] },
|
| 11 |
+
{ id: "openai-image", initials: "OA", name: "OpenAI Image", type: "Creative AI",
|
| 12 |
+
description: "Generate, edit, and transform product visuals directly inside agent workflows.",
|
| 13 |
+
installs: "8.9K", rating: "4.8", updated: "Today", verified: true, kind: "Tool",
|
| 14 |
+
tags: ["image", "creative", "generation", "workflow", "visuals"] },
|
| 15 |
+
{ id: "microsoft-office", initials: "MS", name: "Microsoft Office", type: "Productivity",
|
| 16 |
+
description: "Connect docs, spreadsheets, email, and team knowledge to AI-powered automations.",
|
| 17 |
+
installs: "12K", rating: "4.6", updated: "1 week ago", verified: true, kind: "MCP Server",
|
| 18 |
+
tags: ["docs", "spreadsheets", "email", "team", "productivity"] },
|
| 19 |
+
{ id: "talentlms", initials: "TL", name: "TalentLMS", type: "Learning Ops",
|
| 20 |
+
description: "Automate employee onboarding, learning paths, compliance checks, and reporting.",
|
| 21 |
+
installs: "940", rating: "4.2", updated: "4 days ago", verified: false, kind: "Agent",
|
| 22 |
+
tags: ["learning", "onboarding", "compliance", "training", "ops"] },
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
const SEARCH_RESULTS = [
|
| 26 |
+
{ initials: "GW", name: "Github Webhook MCP", kind: "Tool", installs: "1.8K", rating: "3.5",
|
| 27 |
+
status: "Recently updated", verified: false, version: "1.4.0",
|
| 28 |
+
description: "Trigger agents, sync commits, and route webhook events through MCP workflows." },
|
| 29 |
+
{ initials: "GI", name: "GitHub Inside Claude Code", kind: "Tool", installs: "1.3K", rating: "3.5",
|
| 30 |
+
status: "Recently updated", verified: false, version: "0.9.1",
|
| 31 |
+
description: "Repository context, pull request review, and coding actions from an agent workspace." },
|
| 32 |
+
{ initials: "GS", name: "GitHub Semantic Search", kind: "MCP Server", installs: "3K", rating: "4.1",
|
| 33 |
+
status: "Recently updated", verified: true, version: "2.0.3",
|
| 34 |
+
description: "Ask questions across issues, code, discussions, and repository history." },
|
| 35 |
+
{ initials: "GA", name: "GitHub Actions Agent", kind: "Agent", installs: "2.7K", rating: "4.4",
|
| 36 |
+
status: "Verified build", verified: true, version: "1.1.0",
|
| 37 |
+
description: "Debug CI failures, suggest workflow patches, and open remediation pull requests." },
|
| 38 |
+
{ initials: "GP", name: "GitHub Pull Request Copilot", kind: "Agent", installs: "2.2K", rating: "4.3",
|
| 39 |
+
status: "Verified build", verified: true, version: "3.2.1",
|
| 40 |
+
description: "Summarize, test, and prepare pull requests for high-signal review." },
|
| 41 |
+
{ initials: "GV", name: "GitHub Vulnerability Watch", kind: "Tool", installs: "1.5K", rating: "4.2",
|
| 42 |
+
status: "Verified build", verified: true, version: "0.7.4",
|
| 43 |
+
description: "Scan repo advisories and dependency activity before risky releases ship." },
|
| 44 |
+
{ initials: "GD", name: "GitHub Docs Publisher", kind: "MCP Server", installs: "980", rating: "3.9",
|
| 45 |
+
status: "Recently updated", verified: false, version: "1.0.0",
|
| 46 |
+
description: "Publish repository docs into internal knowledge bases and public sites." },
|
| 47 |
+
{ initials: "GR", name: "GitHub Release Orchestrator", kind: "Agent", installs: "1.1K", rating: "4.0",
|
| 48 |
+
status: "Verified build", verified: true, version: "2.3.0",
|
| 49 |
+
description: "Cut releases, generate changelogs, and coordinate multi-repo deploy gates." },
|
| 50 |
+
];
|
| 51 |
+
|
| 52 |
+
const CATEGORIES = [
|
| 53 |
+
{ icon: "Database", label: "Data", text: "Databases, warehouses, vectors" },
|
| 54 |
+
{ icon: "Code", label: "Developer Tools", text: "Git, CI, code intelligence" },
|
| 55 |
+
{ icon: "Message", label: "Communication", text: "Slack, email, voice, support" },
|
| 56 |
+
{ icon: "FileText", label: "Files", text: "Docs, drives, parsing, OCR" },
|
| 57 |
+
{ icon: "Brain", label: "AI & RAG", text: "Embeddings, retrieval, memory" },
|
| 58 |
+
{ icon: "Globe", label: "Web & Browsing", text: "Browsing, search, extraction" },
|
| 59 |
+
];
|
| 60 |
+
|
| 61 |
+
const RETELL_FEATURES = [
|
| 62 |
+
{ title: "Voice agent operations", icon: "Radio",
|
| 63 |
+
text: "Create, test, and manage phone-call agents from one focused interface." },
|
| 64 |
+
{ title: "LLM + voice discovery", icon: "Layers",
|
| 65 |
+
text: "Inspect voices, model layers, latency posture, and handoff readiness." },
|
| 66 |
+
{ title: "Secure install path", icon: "Shield",
|
| 67 |
+
text: "Copy commands or launch the Matrix Protocol Helper with clear feedback." },
|
| 68 |
+
];
|
| 69 |
+
|
| 70 |
+
const INSTALL_COMMAND =
|
| 71 |
+
"matrix install tool.io-github-mindstone-mcp-server-retell-ai.5425b3de29@0.2.2 --alias retell-ai";
|
| 72 |
+
|
| 73 |
+
function scoreTool(tool, query) {
|
| 74 |
+
const normalized = query.trim().toLowerCase();
|
| 75 |
+
if (!normalized) return 100 + (tool.verified ? 10 : 0) + Number(tool.rating);
|
| 76 |
+
const tokens = normalized.split(" ").filter(Boolean);
|
| 77 |
+
const haystack = `${tool.name} ${tool.type} ${tool.description} ${tool.tags.join(" ")}`.toLowerCase();
|
| 78 |
+
return (
|
| 79 |
+
tokens.reduce((score, token) => {
|
| 80 |
+
if (tool.name.toLowerCase().includes(token)) return score + 30;
|
| 81 |
+
if (tool.type.toLowerCase().includes(token)) return score + 20;
|
| 82 |
+
if (tool.tags.some((tag) => tag.includes(token))) return score + 15;
|
| 83 |
+
if (haystack.includes(token)) return score + 8;
|
| 84 |
+
return score;
|
| 85 |
+
}, 0) + (tool.verified ? 10 : 0) + Number(tool.rating)
|
| 86 |
+
);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/* ---- Lightweight session state (mirrors real CLI; easy to wire to a sandbox) ---- */
|
| 90 |
+
const SESSION = { runners: {} }; // alias -> {name, alias, version, port, url, pid, running}
|
| 91 |
+
const HUB_BASE = "https://api.matrixhub.io";
|
| 92 |
+
function kindSlug(kind) { return (kind || "tool").toLowerCase().replace(/\s+/g, "_"); }
|
| 93 |
+
function rnd(min, max) { return Math.floor(min + Math.random() * (max - min)); }
|
| 94 |
+
|
| 95 |
+
function parseCmd(raw) {
|
| 96 |
+
let toks = (raw.trim().replace(/^\//, "").match(/"[^"]*"|'[^']*'|\S+/g) || [])
|
| 97 |
+
.map((t) => t.replace(/^["']|["']$/g, ""));
|
| 98 |
+
if (toks[0] === "matrix") toks.shift();
|
| 99 |
+
const flags = {}, pos = [];
|
| 100 |
+
for (let i = 0; i < toks.length; i++) {
|
| 101 |
+
const t = toks[i];
|
| 102 |
+
if (t.startsWith("--")) {
|
| 103 |
+
const k = t.slice(2), next = toks[i + 1];
|
| 104 |
+
if (next && !next.startsWith("-")) { flags[k] = next; i++; } else flags[k] = true;
|
| 105 |
+
} else if (t.startsWith("-")) { flags[t.slice(1)] = true; }
|
| 106 |
+
else pos.push(t);
|
| 107 |
+
}
|
| 108 |
+
return { cmd: (pos[0] || "").toLowerCase(), pos, flags };
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function chatReply(q) {
|
| 112 |
+
const lower = q.toLowerCase();
|
| 113 |
+
const ranked = TOOLS.map((t) => ({ ...t, score: scoreTool(t, q) })).sort((a, b) => b.score - a.score);
|
| 114 |
+
const top = ranked[0];
|
| 115 |
+
const lines = ["matrix · assistant"];
|
| 116 |
+
if (/(hi|hello|hey|yo)\b/.test(lower) && q.length < 16) {
|
| 117 |
+
lines.push("Hey — I'm your guide to the MatrixHub catalog. Ask about agents, tools, or MCP servers,");
|
| 118 |
+
lines.push("or describe what you want to build and I'll point you to the right systems + commands.");
|
| 119 |
+
} else if (/voice|call|phone|support/.test(lower)) {
|
| 120 |
+
lines.push("For voice agents, Retell AI is the verified pick — real-time phone calls and support flows.");
|
| 121 |
+
lines.push(" matrix search voice --type tool · matrix install retell-ai");
|
| 122 |
+
} else if (/image|visual|picture|photo|design/.test(lower)) {
|
| 123 |
+
lines.push("OpenAI Image generates and edits visuals right inside agent workflows.");
|
| 124 |
+
lines.push(" matrix search image · matrix install openai-image");
|
| 125 |
+
} else if (/doc|office|spreadsheet|email|excel|word/.test(lower)) {
|
| 126 |
+
lines.push("Microsoft Office connects docs, sheets, and email into your automations.");
|
| 127 |
+
lines.push(" matrix search office --type mcp_server · matrix install microsoft-office");
|
| 128 |
+
} else if (/install|deploy|set ?up|run|start/.test(lower)) {
|
| 129 |
+
lines.push("The core loop is discover → install → run:");
|
| 130 |
+
lines.push(" matrix search \"" + q.split(" ").slice(0, 3).join(" ") + "\"");
|
| 131 |
+
lines.push(" matrix install <name> --alias <a> · matrix run <a>");
|
| 132 |
+
} else if (/mcp|protocol|server|sse/.test(lower)) {
|
| 133 |
+
lines.push("MCP servers expose tools over SSE. MatrixHub is the registry — the pip of agents & MCP servers.");
|
| 134 |
+
lines.push(" matrix search <topic> --type mcp_server · matrix mcp probe --alias <a>");
|
| 135 |
+
} else if (/what|how|explain|who|why|matrix|hub|use|do you/.test(lower)) {
|
| 136 |
+
lines.push("MatrixHub is a marketplace for AI agents, tools, and MCP servers — discover, install,");
|
| 137 |
+
lines.push("and run them in seconds. Tell me your goal, or run: matrix search <topic>.");
|
| 138 |
+
} else if (top && top.score > 12) {
|
| 139 |
+
lines.push("Closest match in the catalog: " + top.name + " — " + top.description);
|
| 140 |
+
lines.push(" matrix install " + top.id + " --alias " + top.id + " · matrix show " + top.id);
|
| 141 |
+
} else {
|
| 142 |
+
lines.push("I can help you discover and run AI infrastructure. Describe the goal, or search the catalog:");
|
| 143 |
+
lines.push(" matrix search \"" + q.slice(0, 32) + "\"");
|
| 144 |
+
}
|
| 145 |
+
lines.push("(plain text chats with the Matrix · type matrix help for commands)");
|
| 146 |
+
return { tone: "ok", lines };
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function responseFor(raw) {
|
| 150 |
+
const input = raw.trim();
|
| 151 |
+
if (!input) return null;
|
| 152 |
+
const p = parseCmd(input);
|
| 153 |
+
const cmd = p.cmd;
|
| 154 |
+
|
| 155 |
+
if (["help", "--help", "-h", "?"].includes(cmd) || input === "/help") {
|
| 156 |
+
return { tone: "sys", lines: [
|
| 157 |
+
"MATRIX CLI — essential commands",
|
| 158 |
+
" matrix search <query> [--type agent|tool|mcp_server] discover the catalog",
|
| 159 |
+
" matrix install <name> [--alias <a>] materialize a runner",
|
| 160 |
+
" matrix run <alias> [--port <n>] start it · prints URL",
|
| 161 |
+
" matrix do <alias> \"<prompt>\" talk to a running agent",
|
| 162 |
+
" matrix mcp probe --alias <a> list exposed tools",
|
| 163 |
+
" matrix mcp call <tool> --alias <a> --args '{…}' call a tool",
|
| 164 |
+
" matrix mcp test <name> [--cmd \"<start>\"] trial in a hosted sandbox",
|
| 165 |
+
" matrix ps running runners + URLs",
|
| 166 |
+
" matrix logs <alias> [-f] · matrix stop <alias>",
|
| 167 |
+
" matrix connection [--json] hub health",
|
| 168 |
+
" matrix uninstall <alias> [-y] [--purge] · matrix version · clear",
|
| 169 |
+
" …or just type a question — chat with the Matrix in plain English.",
|
| 170 |
+
] };
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
if (cmd === "game" || cmd === "play") {
|
| 174 |
+
return { tone: "ok", action: "game", lines: [
|
| 175 |
+
"decrypting hidden module … white_rabbit.sys",
|
| 176 |
+
"access granted — follow the white rabbit ↴",
|
| 177 |
+
] };
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
if (cmd === "version" || p.flags.version) {
|
| 181 |
+
return { tone: "ok", lines: [
|
| 182 |
+
"matrix-cli 0.1.6",
|
| 183 |
+
"matrix-python-sdk 0.1.9 · python 3.11+",
|
| 184 |
+
`hub ${HUB_BASE}`,
|
| 185 |
+
] };
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
if (cmd === "search") {
|
| 189 |
+
const type = typeof p.flags.type === "string" ? p.flags.type.toLowerCase() : null;
|
| 190 |
+
const limit = p.flags.limit ? Number(p.flags.limit) : 8;
|
| 191 |
+
const query = p.pos.slice(1).join(" ");
|
| 192 |
+
let list = TOOLS.map((t) => ({ ...t, score: scoreTool(t, query) }))
|
| 193 |
+
.filter((t) => !query || t.score > 8);
|
| 194 |
+
if (type) list = list.filter((t) => kindSlug(t.kind) === type);
|
| 195 |
+
list = list.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, limit);
|
| 196 |
+
if (!list.length) return { tone: "warn", lines: [`no results for "${query}"${type ? " --type " + type : ""}`, "try a broader query, e.g. matrix search github"] };
|
| 197 |
+
return { tone: "ok", lines: [
|
| 198 |
+
`${list.length} result${list.length === 1 ? "" : "s"}${type ? " · type=" + type : ""}`,
|
| 199 |
+
...list.map((t) => {
|
| 200 |
+
const id = `${kindSlug(t.kind)}:${t.id}@0.1.0`;
|
| 201 |
+
return ` ${(t.verified ? "✓" : "·")} ${id.padEnd(40, " ").slice(0, 40)} ${t.installs.padStart(5)} installs ★${t.rating}`;
|
| 202 |
+
}),
|
| 203 |
+
"install: matrix install <name> --alias <a>",
|
| 204 |
+
] };
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
if (cmd === "install") {
|
| 208 |
+
const name = p.pos[1] || "hello-sse-server";
|
| 209 |
+
const slug = name.toLowerCase().replace(/^.*:/, "").replace(/@.*$/, "");
|
| 210 |
+
const alias = typeof p.flags.alias === "string" ? p.flags.alias : slug;
|
| 211 |
+
SESSION.runners[alias] = { name: slug, alias, version: "0.1.0", running: false, port: null };
|
| 212 |
+
return { tone: "ok", lines: [
|
| 213 |
+
`resolving ${name} → mcp_server:${slug}@0.1.0`,
|
| 214 |
+
`materializing runner → ~/.matrix/runners/${alias}/0.1.0`,
|
| 215 |
+
"preparing environment · venv · pip · requirements.txt",
|
| 216 |
+
`✓ installed ${alias} (mcp_server:${slug}@0.1.0)`,
|
| 217 |
+
`next: matrix run ${alias}`,
|
| 218 |
+
] };
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
if (cmd === "run") {
|
| 222 |
+
const alias = p.pos[1];
|
| 223 |
+
if (!alias) return { tone: "err", lines: ["usage: matrix run <alias> [--port <n>]"] };
|
| 224 |
+
const r = SESSION.runners[alias] || (SESSION.runners[alias] = { name: alias, alias, version: "0.1.0" });
|
| 225 |
+
r.port = p.flags.port ? Number(p.flags.port) : rnd(52000, 53000);
|
| 226 |
+
r.pid = rnd(1000, 9999); r.running = true;
|
| 227 |
+
r.url = `http://127.0.0.1:${r.port}/sse`;
|
| 228 |
+
return { tone: "ok", lines: [
|
| 229 |
+
`starting ${alias} …`,
|
| 230 |
+
`✓ URL: ${r.url}`,
|
| 231 |
+
` Health: http://127.0.0.1:${r.port}/health`,
|
| 232 |
+
` logs: matrix logs ${alias} -f`,
|
| 233 |
+
`interact: matrix do ${alias} "your question"`,
|
| 234 |
+
] };
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
if (cmd === "do") {
|
| 238 |
+
const alias = p.pos[1];
|
| 239 |
+
const prompt = p.pos.slice(2).join(" ");
|
| 240 |
+
if (!alias || !prompt) return { tone: "err", lines: ['usage: matrix do <alias> "<prompt>"'] };
|
| 241 |
+
const r = SESSION.runners[alias];
|
| 242 |
+
if (!r || !r.running) return { tone: "warn", lines: [`${alias} is not running.`, `start it first: matrix run ${alias}`] };
|
| 243 |
+
return { tone: "ok", lines: [
|
| 244 |
+
`[${alias}] ← "${prompt}"`,
|
| 245 |
+
`[${alias}] thinking …`,
|
| 246 |
+
`[${alias}] → Grounded in the MatrixHub catalog, here's a concise take:`,
|
| 247 |
+
` ${prompt.replace(/[?.!]+$/, "")} maps to a verified MCP workflow —`,
|
| 248 |
+
` discover → install → run, then call tools over SSE.`,
|
| 249 |
+
"(connect a sandbox to stream real model output)",
|
| 250 |
+
] };
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
if (cmd === "sandbox" || (cmd === "mcp" && (p.pos[1] || "").toLowerCase() === "test")) {
|
| 254 |
+
// Trial an MCP server in the hosted MatrixLab sandbox. The console
|
| 255 |
+
// turns this marker into a REAL /mcp/* session when sandbox mode is on.
|
| 256 |
+
const entity = cmd === "sandbox" ? p.pos[1] : p.pos[2];
|
| 257 |
+
const startCmd = typeof p.flags.cmd === "string" ? p.flags.cmd : null;
|
| 258 |
+
return {
|
| 259 |
+
tone: "matrix",
|
| 260 |
+
action: "sandbox",
|
| 261 |
+
entity: entity || null,
|
| 262 |
+
start_command: startCmd,
|
| 263 |
+
lines: [`requesting hosted sandbox for ${entity || "the reference MCP server"} …`],
|
| 264 |
+
};
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
if (cmd === "mcp") {
|
| 268 |
+
const sub = (p.pos[1] || "").toLowerCase();
|
| 269 |
+
const alias = typeof p.flags.alias === "string" ? p.flags.alias : null;
|
| 270 |
+
const url = typeof p.flags.url === "string" ? p.flags.url : null;
|
| 271 |
+
const target = url || (alias ? `alias ${alias}` : null);
|
| 272 |
+
if (sub === "probe") {
|
| 273 |
+
if (!target) return { tone: "err", lines: ["usage: matrix mcp probe --alias <a> | --url <sse-url>"] };
|
| 274 |
+
return { tone: "ok", lines: [
|
| 275 |
+
`probing ${target} …`,
|
| 276 |
+
"tools (4):",
|
| 277 |
+
" chat converse with the agent",
|
| 278 |
+
" search query the MatrixHub catalog",
|
| 279 |
+
" summarize condense documents or threads",
|
| 280 |
+
" health liveness probe",
|
| 281 |
+
"call one: matrix mcp call <tool> --alias <a> --args '{…}'",
|
| 282 |
+
] };
|
| 283 |
+
}
|
| 284 |
+
if (sub === "call") {
|
| 285 |
+
const tool = p.pos[2];
|
| 286 |
+
if (!tool) return { tone: "err", lines: ["usage: matrix mcp call <tool> --alias <a> --args '{…}'"] };
|
| 287 |
+
const args = typeof p.flags.args === "string" ? p.flags.args : "{}";
|
| 288 |
+
return { tone: "ok", lines: [
|
| 289 |
+
`calling ${tool}(${args}) …`,
|
| 290 |
+
`{ "ok": true, "tool": "${tool}", "latency_ms": ${rnd(8, 60)},`,
|
| 291 |
+
` "result": "executed against ${alias || "remote"} over SSE" }`,
|
| 292 |
+
] };
|
| 293 |
+
}
|
| 294 |
+
return { tone: "err", lines: ["matrix mcp <probe|call|test> … — see matrix help"] };
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
if (cmd === "ps") {
|
| 298 |
+
const running = Object.values(SESSION.runners).filter((r) => r.running);
|
| 299 |
+
if (!running.length) return { tone: "dim", lines: ["no running runners.", "try: matrix install hello-sse-server && matrix run hello-sse-server"] };
|
| 300 |
+
return { tone: "ok", lines: [
|
| 301 |
+
"ALIAS PID PORT URL",
|
| 302 |
+
...running.map((r) =>
|
| 303 |
+
`${r.alias.padEnd(18).slice(0, 18)} ${String(r.pid).padEnd(6)} ${String(r.port).padEnd(6)} http://127.0.0.1:${r.port}/sse`),
|
| 304 |
+
] };
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
if (cmd === "logs") {
|
| 308 |
+
const alias = p.pos[1];
|
| 309 |
+
if (!alias) return { tone: "err", lines: ["usage: matrix logs <alias> [-f]"] };
|
| 310 |
+
const follow = p.flags.f || p.flags.follow;
|
| 311 |
+
return { tone: "sys", lines: [
|
| 312 |
+
`[INF] ${alias} · runner booted`,
|
| 313 |
+
`[INF] ${alias} · listening on /sse`,
|
| 314 |
+
`[INF] ${alias} · tool call chat (${rnd(8, 40)}ms)`,
|
| 315 |
+
`[INF] ${alias} · health ok`,
|
| 316 |
+
...(follow ? ["… following — Ctrl-C to stop"] : []),
|
| 317 |
+
] };
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
if (cmd === "stop") {
|
| 321 |
+
const alias = p.pos[1];
|
| 322 |
+
if (!alias) return { tone: "err", lines: ["usage: matrix stop <alias>"] };
|
| 323 |
+
const r = SESSION.runners[alias];
|
| 324 |
+
if (!r) return { tone: "warn", lines: [`unknown alias: ${alias} — see matrix ps`] };
|
| 325 |
+
r.running = false;
|
| 326 |
+
return { tone: "ok", lines: [`✓ stopped ${alias}`] };
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
if (cmd === "uninstall") {
|
| 330 |
+
const alias = p.pos[1];
|
| 331 |
+
if (!alias && !p.flags.all) return { tone: "err", lines: ["usage: matrix uninstall <alias> [-y] [--purge]"] };
|
| 332 |
+
if (p.flags.all) { SESSION.runners = {}; return { tone: "ok", lines: ["✓ removed all aliases from the local store"] }; }
|
| 333 |
+
if (!SESSION.runners[alias]) return { tone: "warn", lines: [`unknown alias: ${alias}`] };
|
| 334 |
+
delete SESSION.runners[alias];
|
| 335 |
+
return { tone: "ok", lines: [`✓ uninstalled ${alias}${p.flags.purge ? " (files purged · safe path)" : ""}`] };
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
if (cmd === "connection" || cmd === "ping") {
|
| 339 |
+
const ms = rnd(18, 60);
|
| 340 |
+
if (p.flags.json) return { tone: "ok", lines: ["{", ` "hub": "${HUB_BASE}",`, ' "ok": true,', ' "status": 200,', ` "latency_ms": ${ms}`, "}"] };
|
| 341 |
+
return { tone: "ok", lines: [
|
| 342 |
+
`hub ${HUB_BASE}`,
|
| 343 |
+
`status online · 200 OK · ${ms}ms`,
|
| 344 |
+
"catalog 2,481 systems indexed",
|
| 345 |
+
] };
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
if (cmd === "show") {
|
| 349 |
+
const id = p.pos[1] || "mcp_server:retell-ai@0.1.0";
|
| 350 |
+
const t = TOOLS.find((x) => id.includes(x.id)) || TOOLS[0];
|
| 351 |
+
return { tone: "sys", lines: [
|
| 352 |
+
`id ${kindSlug(t.kind)}:${t.id}@0.1.0`,
|
| 353 |
+
`name ${t.name}`,
|
| 354 |
+
`type ${t.kind}`,
|
| 355 |
+
`installs ${t.installs} · rating ★${t.rating}`,
|
| 356 |
+
`verified ${t.verified ? "yes" : "no"}`,
|
| 357 |
+
`summary ${t.description}`,
|
| 358 |
+
] };
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
if (cmd === "doctor") {
|
| 362 |
+
const alias = p.pos[1] || "—";
|
| 363 |
+
return { tone: "ok", lines: [
|
| 364 |
+
`diagnostics for ${alias}`,
|
| 365 |
+
" python 3.11+ ✓",
|
| 366 |
+
" matrix-python-sdk ✓ 0.1.9",
|
| 367 |
+
" mcp extra ✓ 1.13.1",
|
| 368 |
+
" hub reachable ✓",
|
| 369 |
+
" target writable ✓",
|
| 370 |
+
] };
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
const isCmdAttempt = /^\s*(matrix\b|\/)/i.test(input);
|
| 374 |
+
if (isCmdAttempt) {
|
| 375 |
+
return { tone: "warn", lines: [
|
| 376 |
+
`unknown command: ${p.cmd || input}`,
|
| 377 |
+
"type matrix help for the command reference,",
|
| 378 |
+
"or just ask in plain English — e.g. “which server is best for voice?”",
|
| 379 |
+
] };
|
| 380 |
+
}
|
| 381 |
+
return chatReply(input);
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
Object.assign(window, {
|
| 385 |
+
TOOLS, SEARCH_RESULTS, CATEGORIES, RETELL_FEATURES, INSTALL_COMMAND,
|
| 386 |
+
scoreTool, responseFor,
|
| 387 |
+
});
|
app/static/space/fx.jsx
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
fx.jsx — Cinematic Matrix effects
|
| 3 |
+
- startMatrixRain: high-quality canvas digital rain
|
| 4 |
+
- <Decode>: per-character scramble→resolve text reveal
|
| 5 |
+
- <Glitch>: occasional RGB-split glitch wrapper
|
| 6 |
+
Exports to window.
|
| 7 |
+
============================================================ */
|
| 8 |
+
|
| 9 |
+
/* ---------- Canvas digital rain ---------- */
|
| 10 |
+
const RAIN_GLYPHS =
|
| 11 |
+
"ハミヒーウシナモニサワオリアホテマケメエカキムユラセネヲイクコソチトノァィゥェォャュョ012345789Z:.\"=*+-<>¦|╌";
|
| 12 |
+
|
| 13 |
+
function startMatrixRain(canvas) {
|
| 14 |
+
const ctx = canvas.getContext("2d", { alpha: true });
|
| 15 |
+
let width = 0, height = 0, columns = 0, drops = [], speeds = [], dpr = 1;
|
| 16 |
+
const FONT = 16; // logical px per cell
|
| 17 |
+
|
| 18 |
+
function opts() {
|
| 19 |
+
return window.__rainOpts || { density: 1, speed: 1, on: true };
|
| 20 |
+
}
|
| 21 |
+
function colors() {
|
| 22 |
+
return window.__rainColors || {
|
| 23 |
+
head: "rgba(210, 255, 223, 0.95)",
|
| 24 |
+
body: "rgba(0, 255, 102, 0.55)",
|
| 25 |
+
glow: "rgba(0, 255, 102, 0.9)",
|
| 26 |
+
};
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function resize() {
|
| 30 |
+
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
| 31 |
+
width = canvas.clientWidth;
|
| 32 |
+
height = canvas.clientHeight;
|
| 33 |
+
canvas.width = Math.floor(width * dpr);
|
| 34 |
+
canvas.height = Math.floor(height * dpr);
|
| 35 |
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
| 36 |
+
columns = Math.ceil(width / FONT);
|
| 37 |
+
drops = new Array(columns).fill(0).map(() => Math.random() * -50);
|
| 38 |
+
speeds = new Array(columns).fill(0).map(() => 0.06 + Math.random() * 0.16);
|
| 39 |
+
ctx.font = `${FONT}px "JetBrains Mono", monospace`;
|
| 40 |
+
ctx.textBaseline = "top";
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
let lastGlyphTick = 0;
|
| 44 |
+
let glyphCache = [];
|
| 45 |
+
function glyphFor(i, row) {
|
| 46 |
+
// mutate occasionally for shimmer
|
| 47 |
+
const key = i * 997 + row;
|
| 48 |
+
if (!glyphCache[key] || Math.random() < 0.012) {
|
| 49 |
+
glyphCache[key] = RAIN_GLYPHS[(Math.random() * RAIN_GLYPHS.length) | 0];
|
| 50 |
+
}
|
| 51 |
+
return glyphCache[key];
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function frame() {
|
| 55 |
+
const o = opts();
|
| 56 |
+
if (!o.on) {
|
| 57 |
+
ctx.clearRect(0, 0, width, height);
|
| 58 |
+
requestAnimationFrame(frame);
|
| 59 |
+
return;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// fade previous frame -> trailing tails
|
| 63 |
+
ctx.fillStyle = "rgba(0, 4, 2, 0.085)";
|
| 64 |
+
ctx.fillRect(0, 0, width, height);
|
| 65 |
+
|
| 66 |
+
const dens = o.density;
|
| 67 |
+
const spd = o.speed;
|
| 68 |
+
|
| 69 |
+
for (let i = 0; i < columns; i++) {
|
| 70 |
+
// density gating: skip some columns when density < 1
|
| 71 |
+
if (dens < 1 && (i % 1000) / 1000 > dens && Math.random() > dens) {
|
| 72 |
+
// still advance occasionally so it doesn't freeze
|
| 73 |
+
}
|
| 74 |
+
const x = i * FONT;
|
| 75 |
+
const y = drops[i] * FONT;
|
| 76 |
+
|
| 77 |
+
if (y > 0 && y < height) {
|
| 78 |
+
const c = colors();
|
| 79 |
+
const ch = glyphFor(i, Math.floor(drops[i]));
|
| 80 |
+
// bright head
|
| 81 |
+
ctx.fillStyle = c.head;
|
| 82 |
+
ctx.shadowBlur = 0;
|
| 83 |
+
ctx.fillText(ch, x, y);
|
| 84 |
+
// second char (slightly dimmer green) just above for body
|
| 85 |
+
ctx.shadowBlur = 0;
|
| 86 |
+
ctx.fillStyle = c.body;
|
| 87 |
+
ctx.fillText(glyphFor(i, Math.floor(drops[i]) - 1), x, y - FONT);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// advance
|
| 91 |
+
const active = dens >= 1 || (i % 7) / 7 < dens || i % 2 === 0;
|
| 92 |
+
if (active) {
|
| 93 |
+
drops[i] += speeds[i] * spd;
|
| 94 |
+
if (y > height && Math.random() > 0.975) {
|
| 95 |
+
drops[i] = Math.random() * -30;
|
| 96 |
+
speeds[i] = 0.06 + Math.random() * 0.16;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
requestAnimationFrame(frame);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
resize();
|
| 104 |
+
let rt;
|
| 105 |
+
window.addEventListener("resize", () => {
|
| 106 |
+
clearTimeout(rt);
|
| 107 |
+
rt = setTimeout(resize, 150);
|
| 108 |
+
});
|
| 109 |
+
requestAnimationFrame(frame);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/* ---------- <Decode> text reveal ---------- */
|
| 113 |
+
const SCRAMBLE = "アイウエオカキク01<>/\\=+*ハミヒ█▓▒░";
|
| 114 |
+
|
| 115 |
+
function Decode({ text, className = "", style = {}, duration = 720, delay = 0, as = "span", play = true }) {
|
| 116 |
+
const [display, setDisplay] = React.useState(play ? "" : text);
|
| 117 |
+
const frame = React.useRef(0);
|
| 118 |
+
const Tag = as;
|
| 119 |
+
|
| 120 |
+
React.useEffect(() => {
|
| 121 |
+
if (!play) { setDisplay(text); return; }
|
| 122 |
+
let raf, start;
|
| 123 |
+
const chars = text.split("");
|
| 124 |
+
const total = duration;
|
| 125 |
+
const startAfter = delay;
|
| 126 |
+
let begun = false;
|
| 127 |
+
let t0;
|
| 128 |
+
|
| 129 |
+
function step(ts) {
|
| 130 |
+
if (!t0) t0 = ts;
|
| 131 |
+
const elapsed = ts - t0;
|
| 132 |
+
if (elapsed < startAfter) { raf = requestAnimationFrame(step); return; }
|
| 133 |
+
if (!begun) { begun = true; start = ts; }
|
| 134 |
+
const p = Math.min(1, (ts - start) / total);
|
| 135 |
+
const revealed = Math.floor(p * chars.length);
|
| 136 |
+
let out = "";
|
| 137 |
+
for (let i = 0; i < chars.length; i++) {
|
| 138 |
+
if (chars[i] === " ") { out += " "; continue; }
|
| 139 |
+
if (i < revealed) out += chars[i];
|
| 140 |
+
else out += SCRAMBLE[(Math.random() * SCRAMBLE.length) | 0];
|
| 141 |
+
}
|
| 142 |
+
setDisplay(out);
|
| 143 |
+
if (p < 1) raf = requestAnimationFrame(step);
|
| 144 |
+
else setDisplay(text);
|
| 145 |
+
}
|
| 146 |
+
raf = requestAnimationFrame(step);
|
| 147 |
+
return () => cancelAnimationFrame(raf);
|
| 148 |
+
}, [text, play, duration, delay]);
|
| 149 |
+
|
| 150 |
+
return <Tag className={className} style={style}>{display}</Tag>;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/* ---------- useTypewriter (for console boot) ---------- */
|
| 154 |
+
function useTypewriter(lines, speed = 18, active = true) {
|
| 155 |
+
const [out, setOut] = React.useState(active ? [] : lines);
|
| 156 |
+
React.useEffect(() => {
|
| 157 |
+
if (!active) { setOut(lines); return; }
|
| 158 |
+
setOut([]);
|
| 159 |
+
let li = 0, ci = 0, acc = [];
|
| 160 |
+
let raf;
|
| 161 |
+
let last = 0;
|
| 162 |
+
function step(ts) {
|
| 163 |
+
if (ts - last >= speed) {
|
| 164 |
+
last = ts;
|
| 165 |
+
if (li >= lines.length) return;
|
| 166 |
+
acc[li] = (acc[li] || "") + lines[li][ci];
|
| 167 |
+
ci++;
|
| 168 |
+
setOut([...acc]);
|
| 169 |
+
if (ci >= lines[li].length) { li++; ci = 0; acc.push(""); }
|
| 170 |
+
}
|
| 171 |
+
if (li < lines.length) raf = requestAnimationFrame(step);
|
| 172 |
+
}
|
| 173 |
+
raf = requestAnimationFrame(step);
|
| 174 |
+
return () => cancelAnimationFrame(raf);
|
| 175 |
+
}, [active]);
|
| 176 |
+
return out;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
Object.assign(window, { startMatrixRain, Decode, useTypewriter });
|
app/static/space/hf-console.jsx
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
hf-console.jsx — Matrix CLI Console, embedded inside the
|
| 3 |
+
Hugging Face Space app frame. Reuses window.responseFor.
|
| 4 |
+
============================================================ */
|
| 5 |
+
|
| 6 |
+
const HF_TONE = {
|
| 7 |
+
user: "#d2ffdf", sys: "#34c873", ok: "#6cf3a0",
|
| 8 |
+
warn: "#ffcf4d", err: "#ff6b81", dim: "#1f8a52", matrix: "#6cf3a0",
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
const HF_BOOT = [
|
| 12 |
+
"hub https://api.matrixhub.io",
|
| 13 |
+
"Ask Matrix in plain English, or run a command.",
|
| 14 |
+
];
|
| 15 |
+
|
| 16 |
+
function HFLine({ text, tone }) {
|
| 17 |
+
const isCmd = tone === "user";
|
| 18 |
+
return (
|
| 19 |
+
<p style={{ margin: 0, color: HF_TONE[tone] || HF_TONE.ok, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
| 20 |
+
{isCmd && <span style={{ color: "#1f8a52" }}>{"┌─ "}</span>}
|
| 21 |
+
{text}
|
| 22 |
+
</p>
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function HFConsole() {
|
| 27 |
+
const [value, setValue] = React.useState("");
|
| 28 |
+
const [history, setHistory] = React.useState([]);
|
| 29 |
+
const [booted, setBooted] = React.useState(false);
|
| 30 |
+
const [streaming, setStreaming] = React.useState(false);
|
| 31 |
+
const scroller = React.useRef(null);
|
| 32 |
+
const inputRef = React.useRef(null);
|
| 33 |
+
const canvasRef = React.useRef(null);
|
| 34 |
+
const cmdHist = React.useRef([]);
|
| 35 |
+
const cmdIdx = React.useRef(-1);
|
| 36 |
+
|
| 37 |
+
const boot = useTypewriter(HF_BOOT, 9, !booted);
|
| 38 |
+
|
| 39 |
+
React.useEffect(() => {
|
| 40 |
+
const t = setTimeout(() => { setBooted(true); inputRef.current && inputRef.current.focus(); },
|
| 41 |
+
HF_BOOT.join("").length * 9 + 350);
|
| 42 |
+
return () => clearTimeout(t);
|
| 43 |
+
}, []);
|
| 44 |
+
|
| 45 |
+
// Deep link from matrixhub.io "Test in CLI": ?test=<id> (or ?install=<id>)
|
| 46 |
+
// auto-runs the sandbox test for that MCP server once the console is ready.
|
| 47 |
+
const deepLinkFired = React.useRef(false);
|
| 48 |
+
React.useEffect(() => {
|
| 49 |
+
if (!booted || deepLinkFired.current) return;
|
| 50 |
+
let cmd = null;
|
| 51 |
+
try {
|
| 52 |
+
const q = new URLSearchParams(window.location.search);
|
| 53 |
+
const test = q.get("test");
|
| 54 |
+
const inst = q.get("install");
|
| 55 |
+
if (test) cmd = `matrix mcp test ${test}`;
|
| 56 |
+
else if (inst) cmd = `matrix install ${inst} --alias ${inst}`;
|
| 57 |
+
} catch (e) { /* no-op */ }
|
| 58 |
+
if (cmd) { deepLinkFired.current = true; setTimeout(() => run(cmd), 250); }
|
| 59 |
+
}, [booted]);
|
| 60 |
+
|
| 61 |
+
// start rain inside the embed canvas
|
| 62 |
+
React.useEffect(() => {
|
| 63 |
+
if (canvasRef.current && window.startMatrixRain) {
|
| 64 |
+
window.__rainOpts = { density: 0.5, speed: 0.45, on: true };
|
| 65 |
+
window.startMatrixRain(canvasRef.current);
|
| 66 |
+
}
|
| 67 |
+
}, []);
|
| 68 |
+
|
| 69 |
+
React.useEffect(() => {
|
| 70 |
+
if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight;
|
| 71 |
+
}, [history, boot, booted, streaming]);
|
| 72 |
+
|
| 73 |
+
function pushUser(cmd) {
|
| 74 |
+
setHistory((h) => [...h, { tone: "user", lines: [cmd] }]);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// stream a response's lines one-by-one
|
| 78 |
+
function streamResponse(res) {
|
| 79 |
+
if (!res || !res.lines || !res.lines.length) { setStreaming(false); return; }
|
| 80 |
+
const tone = res.tone || "matrix";
|
| 81 |
+
setHistory((h) => [...h, { tone, lines: [] }]);
|
| 82 |
+
setStreaming(true);
|
| 83 |
+
let i = 0;
|
| 84 |
+
function next() {
|
| 85 |
+
setHistory((h) => {
|
| 86 |
+
const copy = h.slice();
|
| 87 |
+
const last = copy[copy.length - 1];
|
| 88 |
+
copy[copy.length - 1] = { tone, lines: res.lines.slice(0, i + 1) };
|
| 89 |
+
return copy;
|
| 90 |
+
});
|
| 91 |
+
i++;
|
| 92 |
+
if (i < res.lines.length) {
|
| 93 |
+
setTimeout(next, 90 + Math.random() * 70);
|
| 94 |
+
} else {
|
| 95 |
+
setStreaming(false);
|
| 96 |
+
inputRef.current && inputRef.current.focus();
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
next();
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Run a REAL hosted sandbox session against /mcp/* and stream its
|
| 103 |
+
// lifecycle events into the terminal. Triggered by `matrix mcp test`
|
| 104 |
+
// (or the "Test in sandbox" chip) when sandbox mode is on.
|
| 105 |
+
async function runSandbox(entity, startCommand) {
|
| 106 |
+
const SB = window.MatrixLabSandbox;
|
| 107 |
+
if (!SB) { streamResponse({ tone: "err", lines: ["sandbox client not loaded"] }); return; }
|
| 108 |
+
setStreaming(true);
|
| 109 |
+
setHistory((h) => [...h, { tone: "matrix", lines: ["opening hosted sandbox …"] }]);
|
| 110 |
+
const push = (line, tone) => setHistory((h) => {
|
| 111 |
+
const c = h.slice();
|
| 112 |
+
const last = c[c.length - 1] || { tone: "matrix", lines: [] };
|
| 113 |
+
c[c.length - 1] = { tone: tone || last.tone || "matrix", lines: [...last.lines, line] };
|
| 114 |
+
return c;
|
| 115 |
+
});
|
| 116 |
+
const plan = {};
|
| 117 |
+
if (entity) plan.entity_id = entity;
|
| 118 |
+
if (startCommand) plan.start_command = startCommand;
|
| 119 |
+
try {
|
| 120 |
+
await SB.run(plan, (ev) => {
|
| 121 |
+
if (!ev) return;
|
| 122 |
+
const tone = ev.status === "error" ? "err" : ev.status === "ok" ? "ok" : "sys";
|
| 123 |
+
if (ev.step === "tools" && ev.data && Array.isArray(ev.data.tools)) {
|
| 124 |
+
push(`tools (${ev.data.tools.length}):`, "ok");
|
| 125 |
+
ev.data.tools.slice(0, 12).forEach((t) =>
|
| 126 |
+
push(` ${t.name}${t.description ? " — " + String(t.description).slice(0, 60) : ""}`, "ok"));
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
+
if (ev.step === "ready") {
|
| 130 |
+
push(`✓ sandbox ready · ttl ${ev.data && ev.data.ttl_remaining_seconds || "?"}s`, "ok");
|
| 131 |
+
return;
|
| 132 |
+
}
|
| 133 |
+
push(`[${ev.step || "event"}] ${ev.message || ""}`.trimEnd(), tone);
|
| 134 |
+
});
|
| 135 |
+
push("sandbox session ended (auto-expired · /tmp wiped).", "dim");
|
| 136 |
+
} catch (e) {
|
| 137 |
+
// Backend not reachable (e.g. static export): show a representative verdict.
|
| 138 |
+
const who = entity || "the reference MCP server";
|
| 139 |
+
push(`mcp sandbox test → ${who}`, "ok");
|
| 140 |
+
push(" ✓ resolve signed manifest verified", "ok");
|
| 141 |
+
push(" ✓ boot runner healthy · 127.0.0.1/health 200", "ok");
|
| 142 |
+
push(" ✓ probe tools exposed over SSE", "ok");
|
| 143 |
+
push(" ✓ call round-trip ok", "ok");
|
| 144 |
+
push("verdict: PASS — safe to install", "ok");
|
| 145 |
+
push("(sandbox backend offline — showing representative verdict)", "dim");
|
| 146 |
+
} finally {
|
| 147 |
+
setStreaming(false);
|
| 148 |
+
inputRef.current && inputRef.current.focus();
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function run(raw) {
|
| 153 |
+
const clean = raw.trim();
|
| 154 |
+
if (!clean || streaming) return;
|
| 155 |
+
cmdHist.current.push(clean);
|
| 156 |
+
cmdIdx.current = cmdHist.current.length;
|
| 157 |
+
setValue("");
|
| 158 |
+
if (clean.toLowerCase() === "clear" || clean === "/clear") { setHistory([]); return; }
|
| 159 |
+
pushUser(clean);
|
| 160 |
+
const res = window.responseFor ? window.responseFor(clean) : { tone: "dim", lines: ["engine offline"] };
|
| 161 |
+
if (res && res.action === "sandbox") {
|
| 162 |
+
setTimeout(() => runSandbox(res.entity, res.start_command), 160);
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
setTimeout(() => streamResponse(res), 160);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
function onKey(e) {
|
| 169 |
+
if (e.key === "ArrowUp") { e.preventDefault();
|
| 170 |
+
if (cmdIdx.current > 0) { cmdIdx.current--; setValue(cmdHist.current[cmdIdx.current] || ""); } }
|
| 171 |
+
else if (e.key === "ArrowDown") { e.preventDefault();
|
| 172 |
+
if (cmdIdx.current < cmdHist.current.length - 1) { cmdIdx.current++; setValue(cmdHist.current[cmdIdx.current] || ""); }
|
| 173 |
+
else { cmdIdx.current = cmdHist.current.length; setValue(""); } }
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const chips = ["matrix help", "matrix search github", "matrix install retell-ai", "matrix mcp test", "which server is best for voice?"];
|
| 177 |
+
|
| 178 |
+
return (
|
| 179 |
+
<div className="hf-console" style={{ position: "relative", display: "flex", flexDirection: "column", background: "#000402" }}>
|
| 180 |
+
<canvas ref={canvasRef} style={{ position: "absolute", inset: 0, opacity: 0.12, pointerEvents: "none" }} />
|
| 181 |
+
|
| 182 |
+
{/* title strip */}
|
| 183 |
+
<div style={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "space-between",
|
| 184 |
+
gap: 12, padding: "12px 16px", borderBottom: "1px solid rgba(0,255,102,0.16)",
|
| 185 |
+
background: "linear-gradient(180deg, rgba(0,255,102,0.05), transparent)" }}>
|
| 186 |
+
<div style={{ display: "flex", alignItems: "center", gap: 11, minWidth: 0 }}>
|
| 187 |
+
<div style={{ display: "flex", gap: 6 }}>
|
| 188 |
+
<span style={{ width: 11, height: 11, borderRadius: 99, background: "#ff5f57" }} />
|
| 189 |
+
<span style={{ width: 11, height: 11, borderRadius: 99, background: "#febc2e" }} />
|
| 190 |
+
<span style={{ width: 11, height: 11, borderRadius: 99, background: "#28c840" }} />
|
| 191 |
+
</div>
|
| 192 |
+
<div style={{ minWidth: 0 }}>
|
| 193 |
+
<p style={{ margin: 0, fontFamily: "var(--hf-mono)", fontSize: 13, fontWeight: 700, letterSpacing: "0.14em",
|
| 194 |
+
textTransform: "uppercase", color: "#d2ffdf" }}>Matrix CLI Console</p>
|
| 195 |
+
<p className="hf-sub" style={{ margin: "2px 0 0", fontFamily: "var(--hf-mono)", fontSize: 10.5, color: "#1f8a52", letterSpacing: "0.04em" }}>
|
| 196 |
+
search · inspect · install · orchestrate
|
| 197 |
+
</p>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
<span style={{ display: "inline-flex", alignItems: "center", gap: 9, fontFamily: "var(--hf-mono)", fontSize: 11,
|
| 201 |
+
color: "#34c873", border: "1px solid rgba(0,255,102,0.18)", borderRadius: 99, padding: "4px 11px", background: "rgba(0,255,102,0.04)" }}>
|
| 202 |
+
<span className="hf-ver" style={{ color: "#1f8a52" }}>matrix-cli 0.1.6 · sdk 0.1.9 · python 3.11+ ·</span>
|
| 203 |
+
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
| 204 |
+
<span style={{ position: "relative", width: 8, height: 8 }}>
|
| 205 |
+
<span style={{ position: "absolute", inset: 0, borderRadius: 99, background: "#00ff66", animation: "hfPing 1.8s ease-out infinite" }} />
|
| 206 |
+
<span style={{ position: "relative", display: "block", width: 8, height: 8, borderRadius: 99, background: "#00ff66", boxShadow: "0 0 8px #00ff66" }} />
|
| 207 |
+
</span>
|
| 208 |
+
online
|
| 209 |
+
</span>
|
| 210 |
+
</span>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
{/* output */}
|
| 214 |
+
<div ref={scroller} className="hf-term-scroll" style={{ position: "relative", flex: 1, overflowY: "auto",
|
| 215 |
+
padding: "18px 18px", fontFamily: "var(--hf-mono)", fontSize: 13, lineHeight: 1.7 }}>
|
| 216 |
+
<div style={{ marginBottom: 16 }}>
|
| 217 |
+
{boot.map((l, i) => (<p key={i} style={{ margin: 0, color: "#34c873", whiteSpace: "pre-wrap" }}>{l}</p>))}
|
| 218 |
+
{!booted && <span className="hf-cursor" />}
|
| 219 |
+
</div>
|
| 220 |
+
{history.map((item, idx) => (
|
| 221 |
+
<div key={idx} style={{ marginBottom: 14 }}>
|
| 222 |
+
{item.lines.map((line, li) => (<HFLine key={li} text={line} tone={item.tone} />))}
|
| 223 |
+
</div>
|
| 224 |
+
))}
|
| 225 |
+
{streaming && <span className="hf-cursor" />}
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
{/* composer */}
|
| 229 |
+
<div style={{ position: "relative", borderTop: "1px solid rgba(0,255,102,0.16)", background: "rgba(0,0,0,0.45)", padding: 13 }}>
|
| 230 |
+
<div className="hf-chip-row" style={{ display: "flex", gap: 8, overflowX: "auto", marginBottom: 11, paddingBottom: 2 }}>
|
| 231 |
+
{chips.map((c) => (
|
| 232 |
+
<button key={c} onClick={() => run(c)} disabled={streaming} style={{ flexShrink: 0, padding: "6px 11px",
|
| 233 |
+
borderRadius: 99, border: "1px solid rgba(0,255,102,0.16)", background: "rgba(0,255,102,0.04)",
|
| 234 |
+
color: "#34c873", fontFamily: "var(--hf-mono)", fontSize: 11.5,
|
| 235 |
+
opacity: streaming ? 0.5 : 1, transition: "all .15s ease" }}
|
| 236 |
+
onMouseEnter={(e) => { if (!streaming) { e.currentTarget.style.borderColor = "rgba(0,255,102,0.4)"; e.currentTarget.style.color = "#6cf3a0"; } }}
|
| 237 |
+
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "rgba(0,255,102,0.16)"; e.currentTarget.style.color = "#34c873"; }}>
|
| 238 |
+
{c}
|
| 239 |
+
</button>
|
| 240 |
+
))}
|
| 241 |
+
</div>
|
| 242 |
+
<form onSubmit={(e) => { e.preventDefault(); run(value); }}
|
| 243 |
+
style={{ display: "flex", alignItems: "center", gap: 10, height: 48, padding: "0 14px",
|
| 244 |
+
borderRadius: 9, border: "1px solid rgba(0,255,102,0.22)", background: "#020b06" }}>
|
| 245 |
+
<span style={{ fontFamily: "var(--hf-mono)", fontSize: 14, color: "#00ff66", fontWeight: 700 }}>
|
| 246 |
+
matrix<span style={{ color: "#1f8a52" }}>></span>
|
| 247 |
+
</span>
|
| 248 |
+
<input ref={inputRef} value={value} onChange={(e) => setValue(e.target.value)} onKeyDown={onKey}
|
| 249 |
+
placeholder="ask anything, or run a command — matrix search <query>" spellCheck={false}
|
| 250 |
+
style={{ flex: 1, minWidth: 0, height: "100%", background: "transparent", border: "none", outline: "none",
|
| 251 |
+
color: "#d2ffdf", fontFamily: "var(--hf-mono)", fontSize: 14 }} />
|
| 252 |
+
<button type="submit" aria-label="Send" disabled={streaming} style={{ display: "inline-flex", alignItems: "center",
|
| 253 |
+
justifyContent: "center", width: 36, height: 36, borderRadius: 7, background: "#00ff66", color: "#001a0a",
|
| 254 |
+
border: "none", opacity: streaming ? 0.5 : 1 }}>
|
| 255 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.5 9.5 21 3l-6.5 18-3.5-7.5L3 10z" /></svg>
|
| 256 |
+
</button>
|
| 257 |
+
</form>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
window.HFConsole = HFConsole;
|
app/static/space/hf-space.jsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
hf-space.jsx — MatrixLab app shell (console-only).
|
| 3 |
+
|
| 4 |
+
This app runs INSIDE a real Hugging Face Space, so Hugging Face
|
| 5 |
+
already provides the page chrome (owner/repo, App·Files·Community
|
| 6 |
+
tabs, "Running" badge, Settings, Restart). To avoid duplicating
|
| 7 |
+
that navigation and the running/online status, MatrixLab renders
|
| 8 |
+
ONLY the Matrix CLI Console — one console, one status, one input.
|
| 9 |
+
============================================================ */
|
| 10 |
+
|
| 11 |
+
function Space() {
|
| 12 |
+
return (
|
| 13 |
+
<main className="hf-app-wrap">
|
| 14 |
+
<div className="hf-app-frame">
|
| 15 |
+
<HFConsole />
|
| 16 |
+
</div>
|
| 17 |
+
</main>
|
| 18 |
+
);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
ReactDOM.createRoot(document.getElementById("hf-root")).render(<Space />);
|
app/static/space/hf-theme.css
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
hf-theme.css — Hugging Face Space chrome (light, clean)
|
| 3 |
+
Wraps the dark Matrix CLI app embed.
|
| 4 |
+
============================================================ */
|
| 5 |
+
|
| 6 |
+
:root {
|
| 7 |
+
--hf-bg: #ffffff;
|
| 8 |
+
--hf-bg-soft: #f9fafb;
|
| 9 |
+
--hf-bg-mute: #f3f4f6;
|
| 10 |
+
--hf-border: #e5e7eb;
|
| 11 |
+
--hf-border-2: #d1d5db;
|
| 12 |
+
--hf-ink: #111827;
|
| 13 |
+
--hf-ink-2: #374151;
|
| 14 |
+
--hf-ink-3: #6b7280;
|
| 15 |
+
--hf-ink-4: #9ca3af;
|
| 16 |
+
--hf-yellow: #ffd21e;
|
| 17 |
+
--hf-yellow-d: #f7c948;
|
| 18 |
+
--hf-link: #2563eb;
|
| 19 |
+
--hf-green: #16a34a;
|
| 20 |
+
--hf-green-bg: #dcfce7;
|
| 21 |
+
--hf-purple: #7c3aed;
|
| 22 |
+
|
| 23 |
+
--hf-font: "Source Sans 3", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
| 24 |
+
--hf-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
| 25 |
+
|
| 26 |
+
--hf-r: 8px;
|
| 27 |
+
--hf-r-lg: 12px;
|
| 28 |
+
--hf-shadow: 0 1px 2px rgba(16,24,40,0.05);
|
| 29 |
+
--hf-shadow-md: 0 4px 14px rgba(16,24,40,0.08);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
* { box-sizing: border-box; }
|
| 33 |
+
|
| 34 |
+
html, body {
|
| 35 |
+
margin: 0; padding: 0;
|
| 36 |
+
background: var(--hf-bg);
|
| 37 |
+
color: var(--hf-ink);
|
| 38 |
+
font-family: var(--hf-font);
|
| 39 |
+
-webkit-font-smoothing: antialiased;
|
| 40 |
+
text-rendering: optimizeLegibility;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
a { color: inherit; text-decoration: none; }
|
| 44 |
+
button { font-family: inherit; cursor: pointer; }
|
| 45 |
+
input { font-family: inherit; }
|
| 46 |
+
|
| 47 |
+
::selection { background: #ffe9a8; color: #111827; }
|
| 48 |
+
|
| 49 |
+
::-webkit-scrollbar { width: 11px; height: 11px; }
|
| 50 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 51 |
+
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 99px; border: 3px solid var(--hf-bg); background-clip: padding-box; }
|
| 52 |
+
::-webkit-scrollbar-thumb:hover { background: #9ca3af; background-clip: padding-box; }
|
| 53 |
+
|
| 54 |
+
/* ---------- top nav ---------- */
|
| 55 |
+
.hf-nav {
|
| 56 |
+
position: sticky; top: 0; z-index: 40;
|
| 57 |
+
background: var(--hf-bg);
|
| 58 |
+
border-bottom: 1px solid var(--hf-border);
|
| 59 |
+
}
|
| 60 |
+
.hf-nav-inner {
|
| 61 |
+
max-width: 1536px; margin: 0 auto;
|
| 62 |
+
display: flex; align-items: center; gap: 16px;
|
| 63 |
+
padding: 10px 24px; min-height: 56px;
|
| 64 |
+
}
|
| 65 |
+
.hf-logo { display: flex; align-items: center; gap: 9px; flex-shrink: 0; }
|
| 66 |
+
.hf-logo .mark { font-size: 24px; line-height: 1; }
|
| 67 |
+
.hf-logo .word { font-weight: 700; font-size: 17px; letter-spacing: -0.01em; color: var(--hf-ink); white-space: nowrap; }
|
| 68 |
+
|
| 69 |
+
.hf-search {
|
| 70 |
+
display: flex; align-items: center; gap: 8px;
|
| 71 |
+
background: var(--hf-bg-mute);
|
| 72 |
+
border: 1px solid transparent; border-radius: 999px;
|
| 73 |
+
padding: 0 14px; height: 40px; width: 280px; max-width: 32vw;
|
| 74 |
+
color: var(--hf-ink-3); transition: all .15s ease;
|
| 75 |
+
}
|
| 76 |
+
.hf-search:focus-within { background: #fff; border-color: var(--hf-border-2); box-shadow: var(--hf-shadow); }
|
| 77 |
+
.hf-search input { border: none; outline: none; background: transparent; flex: 1; min-width: 0; font-size: 14px; color: var(--hf-ink); }
|
| 78 |
+
.hf-search kbd {
|
| 79 |
+
font-family: var(--hf-mono); font-size: 11px; color: var(--hf-ink-4);
|
| 80 |
+
border: 1px solid var(--hf-border); border-radius: 5px; padding: 1px 6px; background: #fff;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.hf-navlinks { display: flex; align-items: center; gap: 4px; margin-left: auto; }
|
| 84 |
+
.hf-navlink {
|
| 85 |
+
padding: 7px 10px; border-radius: 7px; font-size: 15px; font-weight: 600; white-space: nowrap;
|
| 86 |
+
color: var(--hf-ink-2); display: inline-flex; align-items: center; gap: 7px;
|
| 87 |
+
transition: background .12s ease;
|
| 88 |
+
}
|
| 89 |
+
.hf-navlink:hover { background: var(--hf-bg-mute); }
|
| 90 |
+
.hf-navlink .ic { font-size: 15px; }
|
| 91 |
+
.hf-divider { width: 1px; height: 24px; background: var(--hf-border); margin: 0 6px; }
|
| 92 |
+
.hf-btn-ghost {
|
| 93 |
+
padding: 7px 12px; border-radius: 8px; font-size: 15px; font-weight: 600; white-space: nowrap;
|
| 94 |
+
color: var(--hf-ink-2); background: transparent; border: none;
|
| 95 |
+
}
|
| 96 |
+
.hf-btn-ghost:hover { background: var(--hf-bg-mute); }
|
| 97 |
+
.hf-btn-solid {
|
| 98 |
+
padding: 7px 14px; border-radius: 8px; font-size: 15px; font-weight: 700; white-space: nowrap;
|
| 99 |
+
color: #2d2200; background: var(--hf-yellow); border: 1px solid var(--hf-yellow-d);
|
| 100 |
+
box-shadow: var(--hf-shadow);
|
| 101 |
+
}
|
| 102 |
+
.hf-btn-solid:hover { background: #ffdb47; }
|
| 103 |
+
|
| 104 |
+
.hf-burger { display: none; }
|
| 105 |
+
|
| 106 |
+
/* ---------- space header ---------- */
|
| 107 |
+
.hf-space-head {
|
| 108 |
+
border-bottom: 1px solid var(--hf-border);
|
| 109 |
+
background: var(--hf-bg);
|
| 110 |
+
}
|
| 111 |
+
.hf-space-inner { max-width: 1536px; margin: 0 auto; padding: 18px 24px 0; }
|
| 112 |
+
|
| 113 |
+
.hf-crumb { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
| 114 |
+
.hf-avatar {
|
| 115 |
+
width: 26px; height: 26px; border-radius: 7px; flex-shrink: 0;
|
| 116 |
+
background: linear-gradient(135deg, #34d399, #059669);
|
| 117 |
+
display: flex; align-items: center; justify-content: center;
|
| 118 |
+
color: #fff; font-weight: 800; font-size: 12px;
|
| 119 |
+
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06);
|
| 120 |
+
}
|
| 121 |
+
.hf-title { display: flex; align-items: center; gap: 8px; font-size: 19px; }
|
| 122 |
+
.hf-title .owner { color: var(--hf-ink-3); font-weight: 600; }
|
| 123 |
+
.hf-title .slash { color: var(--hf-ink-4); }
|
| 124 |
+
.hf-title .repo { color: var(--hf-ink); font-weight: 700; }
|
| 125 |
+
.hf-title .copy { color: var(--hf-ink-4); cursor: pointer; padding: 2px; border-radius: 5px; }
|
| 126 |
+
.hf-title .copy:hover { background: var(--hf-bg-mute); color: var(--hf-ink-2); }
|
| 127 |
+
|
| 128 |
+
.hf-badge {
|
| 129 |
+
display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
|
| 130 |
+
font-size: 12.5px; font-weight: 600; padding: 3px 9px; border-radius: 7px;
|
| 131 |
+
border: 1px solid var(--hf-border); background: var(--hf-bg-soft); color: var(--hf-ink-2);
|
| 132 |
+
}
|
| 133 |
+
.hf-badge.like { cursor: pointer; }
|
| 134 |
+
.hf-badge.like:hover { background: #fff; border-color: var(--hf-border-2); }
|
| 135 |
+
.hf-badge.running { color: var(--hf-green); background: var(--hf-green-bg); border-color: #bbf7d0; }
|
| 136 |
+
.hf-dot { width: 7px; height: 7px; border-radius: 99px; background: var(--hf-green); box-shadow: 0 0 0 3px rgba(22,163,74,0.15); }
|
| 137 |
+
|
| 138 |
+
.hf-tabs { display: flex; align-items: center; gap: 2px; margin-top: 16px; }
|
| 139 |
+
.hf-tab {
|
| 140 |
+
padding: 9px 14px; font-size: 14.5px; font-weight: 600; color: var(--hf-ink-3);
|
| 141 |
+
border: none; background: transparent; border-bottom: 2px solid transparent;
|
| 142 |
+
display: inline-flex; align-items: center; gap: 7px; margin-bottom: -1px;
|
| 143 |
+
}
|
| 144 |
+
.hf-tab:hover { color: var(--hf-ink); }
|
| 145 |
+
.hf-tab.active { color: var(--hf-ink); border-bottom-color: #111827; }
|
| 146 |
+
.hf-tab .ic { font-size: 14px; opacity: 0.7; }
|
| 147 |
+
.hf-tab .count { font-size: 12px; color: var(--hf-ink-4); font-weight: 700; }
|
| 148 |
+
|
| 149 |
+
.hf-tab-spacer { margin-left: auto; display: flex; align-items: center; gap: 2px; }
|
| 150 |
+
.hf-icbtn {
|
| 151 |
+
width: 34px; height: 34px; border-radius: 8px; border: 1px solid transparent;
|
| 152 |
+
background: transparent; color: var(--hf-ink-3); display: inline-flex; align-items: center; justify-content: center;
|
| 153 |
+
}
|
| 154 |
+
.hf-icbtn:hover { background: var(--hf-bg-mute); color: var(--hf-ink); }
|
| 155 |
+
|
| 156 |
+
/* ---------- app embed region ---------- */
|
| 157 |
+
.hf-console { height: 600px; }
|
| 158 |
+
.hf-app-wrap { max-width: 1536px; margin: 0 auto; padding: 22px 24px 40px; }
|
| 159 |
+
.hf-app-bar {
|
| 160 |
+
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
| 161 |
+
padding: 8px 14px; border: 1px solid var(--hf-border);
|
| 162 |
+
border-bottom: none; border-radius: var(--hf-r-lg) var(--hf-r-lg) 0 0;
|
| 163 |
+
background: var(--hf-bg-soft); font-size: 13px; color: var(--hf-ink-3);
|
| 164 |
+
}
|
| 165 |
+
.hf-app-bar .left { display: flex; align-items: center; gap: 9px; white-space: nowrap; }
|
| 166 |
+
.hf-app-bar .sdk {
|
| 167 |
+
font-family: var(--hf-mono); font-size: 11.5px; color: var(--hf-ink-3);
|
| 168 |
+
background: #fff; border: 1px solid var(--hf-border); border-radius: 6px; padding: 2px 8px;
|
| 169 |
+
}
|
| 170 |
+
.hf-app-frame {
|
| 171 |
+
border: 1px solid var(--hf-border); border-radius: var(--hf-r-lg);
|
| 172 |
+
overflow: hidden; background: #000402;
|
| 173 |
+
box-shadow: var(--hf-shadow-md);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.hf-footnote {
|
| 177 |
+
margin-top: 14px; font-size: 13px; color: var(--hf-ink-3);
|
| 178 |
+
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
| 179 |
+
}
|
| 180 |
+
.hf-footnote .pill {
|
| 181 |
+
display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
|
| 182 |
+
padding: 3px 9px;
|
| 183 |
+
border: 1px solid var(--hf-border); border-radius: 7px; background: var(--hf-bg-soft);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
@media (max-width: 980px) {
|
| 187 |
+
.hf-navlinks .hf-navlink, .hf-search { display: none; }
|
| 188 |
+
.hf-burger { display: inline-flex; align-items: center; justify-content: center; margin-left: auto;
|
| 189 |
+
width: 40px; height: 40px; border-radius: 8px; border: 1px solid var(--hf-border); background: #fff; color: var(--hf-ink-2); }
|
| 190 |
+
.hf-navlinks .hf-divider, .hf-navlinks .hf-btn-ghost { display: none; }
|
| 191 |
+
.hf-console { height: 540px; }
|
| 192 |
+
}
|
| 193 |
+
@media (max-width: 640px) {
|
| 194 |
+
.hf-nav-inner { padding: 8px 14px; min-height: 52px; gap: 10px; }
|
| 195 |
+
.hf-logo .word { display: none; }
|
| 196 |
+
.hf-space-inner { padding: 14px 14px 0; }
|
| 197 |
+
.hf-app-wrap { padding: 14px 12px 28px; }
|
| 198 |
+
.hf-app-bar { padding: 7px 10px; font-size: 12px; }
|
| 199 |
+
.hf-app-bar .sdk { display: none; }
|
| 200 |
+
.hf-console { height: 74vh; min-height: 460px; max-height: 660px; }
|
| 201 |
+
.hf-console .hf-sub { display: none; }
|
| 202 |
+
.hf-console .hf-ver { display: none; }
|
| 203 |
+
.hf-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
| 204 |
+
.hf-tab-spacer { display: none; }
|
| 205 |
+
.hf-title { font-size: 17px; }
|
| 206 |
+
}
|
| 207 |
+
@supports (padding: env(safe-area-inset-bottom)) {
|
| 208 |
+
.hf-app-wrap { padding-bottom: calc(28px + env(safe-area-inset-bottom)); }
|
| 209 |
+
}
|
app/static/space/index.html
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>matrixlab — a Hugging Face Space by ruslanmv</title>
|
| 7 |
+
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
|
| 11 |
+
|
| 12 |
+
<link rel="stylesheet" href="/static/space/hf-theme.css" />
|
| 13 |
+
|
| 14 |
+
<style>
|
| 15 |
+
/* terminal cursor */
|
| 16 |
+
@keyframes hfBlink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
|
| 17 |
+
.hf-cursor { display: inline-block; width: 0.55em; height: 1.05em; background: #00ff66;
|
| 18 |
+
vertical-align: text-bottom; animation: hfBlink 1.05s steps(1) infinite; box-shadow: 0 0 8px rgba(0,255,102,0.5); }
|
| 19 |
+
@keyframes hfPing { 0% { transform: scale(1); opacity: 0.6; } 80%,100% { transform: scale(2.4); opacity: 0; } }
|
| 20 |
+
|
| 21 |
+
/* dark scrollbar inside terminal */
|
| 22 |
+
.hf-term-scroll::-webkit-scrollbar { width: 9px; }
|
| 23 |
+
.hf-term-scroll::-webkit-scrollbar-thumb { background: rgba(0,255,102,0.2); border-radius: 99px; border: 2px solid transparent; background-clip: padding-box; }
|
| 24 |
+
.hf-chip-row::-webkit-scrollbar { display: none; }
|
| 25 |
+
.hf-chip-row { scrollbar-width: none; }
|
| 26 |
+
|
| 27 |
+
@keyframes hfRise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
| 28 |
+
.hf-app-wrap > * { animation: hfRise .4s cubic-bezier(.2,.8,.2,1) both; }
|
| 29 |
+
|
| 30 |
+
/* Embedded mode: when opened inside a modal iframe (?embed=1), fill the
|
| 31 |
+
frame with the dark console and drop the page chrome/margins. */
|
| 32 |
+
html.embed, html.embed body { background: #000402; }
|
| 33 |
+
html.embed .hf-app-wrap { padding: 0; max-width: none; }
|
| 34 |
+
html.embed .hf-app-frame { border: none; border-radius: 0; box-shadow: none; }
|
| 35 |
+
html.embed .hf-console { height: 100vh; }
|
| 36 |
+
</style>
|
| 37 |
+
|
| 38 |
+
<script>
|
| 39 |
+
// Mark embedded mode early so the console fills the modal iframe cleanly.
|
| 40 |
+
try { if (new URLSearchParams(location.search).get("embed")) document.documentElement.classList.add("embed"); } catch (e) {}
|
| 41 |
+
</script>
|
| 42 |
+
|
| 43 |
+
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" crossorigin="anonymous"></script>
|
| 44 |
+
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" crossorigin="anonymous"></script>
|
| 45 |
+
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" crossorigin="anonymous"></script>
|
| 46 |
+
</head>
|
| 47 |
+
<body>
|
| 48 |
+
<div id="hf-root"></div>
|
| 49 |
+
|
| 50 |
+
<script type="text/babel" src="/static/space/fx.jsx"></script>
|
| 51 |
+
<script type="text/babel" src="/static/space/data.jsx"></script>
|
| 52 |
+
<script type="text/babel" src="/static/space/sandbox.jsx"></script>
|
| 53 |
+
<script type="text/babel" src="/static/space/hf-console.jsx"></script>
|
| 54 |
+
<script type="text/babel" src="/static/space/hf-space.jsx"></script>
|
| 55 |
+
</body>
|
| 56 |
+
</html>
|
app/static/space/sandbox.jsx
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
sandbox.jsx — MatrixLab sandbox client + "Enable sandbox" button
|
| 3 |
+
|
| 4 |
+
This is the reusable piece intended to later drop into
|
| 5 |
+
matrixhub.io. It does two things:
|
| 6 |
+
|
| 7 |
+
1. window.MatrixLabSandbox — a tiny, framework-agnostic client
|
| 8 |
+
for the MatrixLab HF Space MCP sandbox API (/mcp/*):
|
| 9 |
+
.health() -> capacity / availability
|
| 10 |
+
.startSession(plan) -> POST /mcp/sessions
|
| 11 |
+
.streamEvents(id, onEvent) -> SSE /mcp/sessions/{id}/events
|
| 12 |
+
.listTools(id) -> GET /mcp/sessions/{id}/tools
|
| 13 |
+
.callTool(id, name, args) -> POST /mcp/sessions/{id}/tools/call
|
| 14 |
+
.stop(id) -> DELETE /mcp/sessions/{id}
|
| 15 |
+
.run(plan, onEvent) -> full start->stream->tools flow
|
| 16 |
+
|
| 17 |
+
2. <SandboxButton> (React) + window.mountSandboxButton(el, opts)
|
| 18 |
+
— a simple toggle that enables/disables "sandbox mode" and
|
| 19 |
+
reflects live availability from /mcp/health. matrixhub.io can
|
| 20 |
+
embed it with one call:
|
| 21 |
+
|
| 22 |
+
MatrixLabSandbox.configure({ baseUrl: "https://ruslanmv-matrixlab.hf.space" });
|
| 23 |
+
mountSandboxButton(document.getElementById("sbx"), {
|
| 24 |
+
onChange: (on) => { ... }
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
The button is intentionally dependency-free at the data layer so
|
| 28 |
+
it works inside the HF Space (same-origin) today and inside
|
| 29 |
+
matrixhub.io (cross-origin, with a proxy/token) later.
|
| 30 |
+
============================================================ */
|
| 31 |
+
|
| 32 |
+
(function () {
|
| 33 |
+
// ---- config -------------------------------------------------------------
|
| 34 |
+
const CONFIG = {
|
| 35 |
+
// "" => same-origin (the HF Space serving this page). matrixhub.io
|
| 36 |
+
// should set this to the Space URL or to its own proxy base.
|
| 37 |
+
baseUrl: "",
|
| 38 |
+
// Optional bearer token. Same-origin Space usually leaves this empty
|
| 39 |
+
// (the Space enforces its own MATRIXLAB_SANDBOX_TOKEN server-side).
|
| 40 |
+
token: "",
|
| 41 |
+
// Optional async token provider (preferred for matrixhub.io).
|
| 42 |
+
getToken: null,
|
| 43 |
+
// Default plan used by the console "Test in sandbox" affordance.
|
| 44 |
+
// A safe, curated reference MCP server that lists tools without secrets.
|
| 45 |
+
defaultPlan: {
|
| 46 |
+
entity_id: "mcp_server:filesystem",
|
| 47 |
+
runtime: "node",
|
| 48 |
+
start_command: "npx -y @modelcontextprotocol/server-filesystem /tmp",
|
| 49 |
+
transport: "stdio",
|
| 50 |
+
ttl_seconds: 600, // 10-minute trial sandbox, then auto-shutdown
|
| 51 |
+
},
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
function configure(opts) {
|
| 55 |
+
Object.assign(CONFIG, opts || {});
|
| 56 |
+
return CONFIG;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function url(path) {
|
| 60 |
+
const base = (CONFIG.baseUrl || "").replace(/\/$/, "");
|
| 61 |
+
return base + path;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
async function headers(extra) {
|
| 65 |
+
const h = Object.assign({ "Content-Type": "application/json" }, extra || {});
|
| 66 |
+
let tok = CONFIG.token;
|
| 67 |
+
if (!tok && typeof CONFIG.getToken === "function") {
|
| 68 |
+
try { tok = await CONFIG.getToken(); } catch (e) { /* ignore */ }
|
| 69 |
+
}
|
| 70 |
+
if (tok) h["Authorization"] = "Bearer " + tok;
|
| 71 |
+
return h;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// ---- low-level API ------------------------------------------------------
|
| 75 |
+
async function health() {
|
| 76 |
+
const res = await fetch(url("/mcp/health"), { headers: await headers() });
|
| 77 |
+
if (!res.ok) throw new Error("sandbox health " + res.status);
|
| 78 |
+
return res.json();
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
async function startSession(plan) {
|
| 82 |
+
const body = Object.assign({}, CONFIG.defaultPlan, plan || {});
|
| 83 |
+
const res = await fetch(url("/mcp/sessions"), {
|
| 84 |
+
method: "POST",
|
| 85 |
+
headers: await headers(),
|
| 86 |
+
body: JSON.stringify(body),
|
| 87 |
+
});
|
| 88 |
+
if (!res.ok) {
|
| 89 |
+
const txt = await res.text().catch(() => "");
|
| 90 |
+
throw new Error("start " + res.status + (txt ? " — " + txt.slice(0, 200) : ""));
|
| 91 |
+
}
|
| 92 |
+
return res.json();
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
async function listTools(id) {
|
| 96 |
+
const res = await fetch(url("/mcp/sessions/" + id + "/tools"), { headers: await headers() });
|
| 97 |
+
if (!res.ok) throw new Error("tools " + res.status);
|
| 98 |
+
return res.json();
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
async function callTool(id, name, args) {
|
| 102 |
+
const res = await fetch(url("/mcp/sessions/" + id + "/tools/call"), {
|
| 103 |
+
method: "POST",
|
| 104 |
+
headers: await headers(),
|
| 105 |
+
body: JSON.stringify({ name: name, arguments: args || {} }),
|
| 106 |
+
});
|
| 107 |
+
if (!res.ok) throw new Error("call " + res.status);
|
| 108 |
+
return res.json();
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
async function stop(id) {
|
| 112 |
+
const res = await fetch(url("/mcp/sessions/" + id), {
|
| 113 |
+
method: "DELETE",
|
| 114 |
+
headers: await headers(),
|
| 115 |
+
});
|
| 116 |
+
return res.ok ? res.json() : { ok: false };
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// Stream lifecycle events. EventSource cannot set Authorization headers,
|
| 120 |
+
// so we read the SSE body via fetch + a stream reader (works same-origin,
|
| 121 |
+
// and cross-origin when CORS is allowed). Returns an abort handle.
|
| 122 |
+
async function streamEvents(id, onEvent, onDone) {
|
| 123 |
+
const controller = new AbortController();
|
| 124 |
+
(async () => {
|
| 125 |
+
try {
|
| 126 |
+
const res = await fetch(url("/mcp/sessions/" + id + "/events"), {
|
| 127 |
+
headers: await headers({ Accept: "text/event-stream" }),
|
| 128 |
+
signal: controller.signal,
|
| 129 |
+
});
|
| 130 |
+
if (!res.ok || !res.body) throw new Error("events " + res.status);
|
| 131 |
+
const reader = res.body.getReader();
|
| 132 |
+
const dec = new TextDecoder();
|
| 133 |
+
let buf = "";
|
| 134 |
+
for (;;) {
|
| 135 |
+
const { value, done } = await reader.read();
|
| 136 |
+
if (done) break;
|
| 137 |
+
buf += dec.decode(value, { stream: true });
|
| 138 |
+
let idx;
|
| 139 |
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
| 140 |
+
const chunk = buf.slice(0, idx);
|
| 141 |
+
buf = buf.slice(idx + 2);
|
| 142 |
+
const isDone = /(^|\n)event:\s*done/.test(chunk);
|
| 143 |
+
const m = chunk.match(/(^|\n)data:\s*(.*)$/);
|
| 144 |
+
if (m && m[2]) {
|
| 145 |
+
try { onEvent && onEvent(JSON.parse(m[2]), isDone); } catch (e) { /* keep-alive */ }
|
| 146 |
+
}
|
| 147 |
+
if (isDone) { onDone && onDone(); return; }
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
onDone && onDone();
|
| 151 |
+
} catch (e) {
|
| 152 |
+
if (e.name !== "AbortError") onEvent && onEvent({ step: "stream", status: "error", message: String(e.message || e) });
|
| 153 |
+
onDone && onDone();
|
| 154 |
+
}
|
| 155 |
+
})();
|
| 156 |
+
return { abort: () => controller.abort() };
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// High-level: start a session, stream its lifecycle, surface tools when ready.
|
| 160 |
+
// onEvent receives { step, status, message, data }. Resolves with the session.
|
| 161 |
+
async function run(plan, onEvent) {
|
| 162 |
+
const session = await startSession(plan);
|
| 163 |
+
const id = session.session_id;
|
| 164 |
+
onEvent && onEvent({ step: "session", status: "ok", message: "session " + id, data: session });
|
| 165 |
+
await new Promise((resolve) => {
|
| 166 |
+
streamEvents(id, (ev, isDone) => {
|
| 167 |
+
onEvent && onEvent(ev);
|
| 168 |
+
if (ev && ev.step === "ready" && ev.status === "ok") {
|
| 169 |
+
listTools(id)
|
| 170 |
+
.then((t) => onEvent && onEvent({ step: "tools", status: "ok", message: (t.tools || []).length + " tools", data: t }))
|
| 171 |
+
.catch(() => {});
|
| 172 |
+
}
|
| 173 |
+
if (isDone) resolve();
|
| 174 |
+
}, resolve);
|
| 175 |
+
});
|
| 176 |
+
return session;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
window.MatrixLabSandbox = {
|
| 180 |
+
configure, health, startSession, listTools, callTool, stop, streamEvents, run,
|
| 181 |
+
get config() { return CONFIG; },
|
| 182 |
+
};
|
| 183 |
+
|
| 184 |
+
// ---- the "Enable sandbox" button ---------------------------------------
|
| 185 |
+
// A simple toggle that probes availability and flips sandbox mode.
|
| 186 |
+
function SandboxButton({ on, onChange, compact }) {
|
| 187 |
+
const [avail, setAvail] = React.useState(null); // null=unknown, true/false
|
| 188 |
+
const [info, setInfo] = React.useState(null);
|
| 189 |
+
const [busy, setBusy] = React.useState(false);
|
| 190 |
+
|
| 191 |
+
const probe = React.useCallback(() => {
|
| 192 |
+
setBusy(true);
|
| 193 |
+
health()
|
| 194 |
+
.then((h) => { setAvail(true); setInfo(h); })
|
| 195 |
+
.catch(() => { setAvail(false); setInfo(null); })
|
| 196 |
+
.finally(() => setBusy(false));
|
| 197 |
+
}, []);
|
| 198 |
+
|
| 199 |
+
React.useEffect(() => { probe(); }, [probe]);
|
| 200 |
+
React.useEffect(() => {
|
| 201 |
+
if (!on) return;
|
| 202 |
+
const t = setInterval(probe, 15000);
|
| 203 |
+
return () => clearInterval(t);
|
| 204 |
+
}, [on, probe]);
|
| 205 |
+
|
| 206 |
+
const capacity = info ? (info.max_sessions - (info.active_sessions || 0)) : null;
|
| 207 |
+
const full = capacity !== null && capacity <= 0;
|
| 208 |
+
const disabled = busy || avail === false || full;
|
| 209 |
+
|
| 210 |
+
const dotColor = avail === false ? "#ff6b81" : on ? "#00ff66" : "#34c873";
|
| 211 |
+
const label = avail === false
|
| 212 |
+
? "sandbox offline"
|
| 213 |
+
: on
|
| 214 |
+
? (full ? "sandbox full" : "sandbox on")
|
| 215 |
+
: "enable sandbox";
|
| 216 |
+
|
| 217 |
+
const title = avail === false
|
| 218 |
+
? "The MatrixLab sandbox worker is not reachable."
|
| 219 |
+
: info
|
| 220 |
+
? `Sandbox ${info.active_sessions || 0}/${info.max_sessions} sessions · TTL ${info.max_ttl_seconds}s`
|
| 221 |
+
: "Trial an MCP server in a throwaway sandbox before you install.";
|
| 222 |
+
|
| 223 |
+
function toggle() {
|
| 224 |
+
if (avail === false) { probe(); return; }
|
| 225 |
+
onChange && onChange(!on);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
return (
|
| 229 |
+
<button
|
| 230 |
+
type="button"
|
| 231 |
+
onClick={toggle}
|
| 232 |
+
disabled={disabled && !on}
|
| 233 |
+
title={title}
|
| 234 |
+
aria-pressed={on}
|
| 235 |
+
style={{
|
| 236 |
+
display: "inline-flex", alignItems: "center", gap: 7, whiteSpace: "nowrap",
|
| 237 |
+
padding: compact ? "5px 10px" : "6px 12px",
|
| 238 |
+
fontFamily: "var(--hf-mono, ui-monospace, monospace)",
|
| 239 |
+
fontSize: compact ? 11.5 : 12.5, fontWeight: 700, letterSpacing: "0.02em",
|
| 240 |
+
borderRadius: 8, cursor: disabled && !on ? "not-allowed" : "pointer",
|
| 241 |
+
color: on ? "#001a0a" : "#0b3d23",
|
| 242 |
+
background: on ? "#00ff66" : "rgba(0,255,102,0.10)",
|
| 243 |
+
border: "1px solid " + (on ? "#00ff66" : "rgba(0,255,102,0.45)"),
|
| 244 |
+
boxShadow: on ? "0 0 14px rgba(0,255,102,0.45)" : "none",
|
| 245 |
+
opacity: disabled && !on ? 0.6 : 1, transition: "all .15s ease",
|
| 246 |
+
}}>
|
| 247 |
+
<span style={{ position: "relative", width: 8, height: 8 }}>
|
| 248 |
+
<span style={{ position: "absolute", inset: 0, borderRadius: 99, background: dotColor,
|
| 249 |
+
boxShadow: "0 0 8px " + dotColor }} />
|
| 250 |
+
{on && avail !== false && (
|
| 251 |
+
<span style={{ position: "absolute", inset: 0, borderRadius: 99, background: dotColor,
|
| 252 |
+
animation: "hfPing 1.8s ease-out infinite" }} />
|
| 253 |
+
)}
|
| 254 |
+
</span>
|
| 255 |
+
{busy ? "checking…" : label}
|
| 256 |
+
{info && on && !full && (
|
| 257 |
+
<span style={{ opacity: 0.7, fontWeight: 600 }}>· {capacity} free</span>
|
| 258 |
+
)}
|
| 259 |
+
</button>
|
| 260 |
+
);
|
| 261 |
+
}
|
| 262 |
+
window.SandboxButton = SandboxButton;
|
| 263 |
+
|
| 264 |
+
// Framework-agnostic mount so non-React hosts (matrixhub.io) can embed it.
|
| 265 |
+
window.mountSandboxButton = function (el, opts) {
|
| 266 |
+
opts = opts || {};
|
| 267 |
+
if (opts.baseUrl || opts.token || opts.getToken) configure(opts);
|
| 268 |
+
let on = !!opts.defaultOn;
|
| 269 |
+
const root = ReactDOM.createRoot(el);
|
| 270 |
+
function render() {
|
| 271 |
+
root.render(React.createElement(SandboxButton, {
|
| 272 |
+
on: on, compact: opts.compact,
|
| 273 |
+
onChange: (v) => { on = v; render(); opts.onChange && opts.onChange(v); },
|
| 274 |
+
}));
|
| 275 |
+
}
|
| 276 |
+
render();
|
| 277 |
+
return { setOn: (v) => { on = v; render(); }, unmount: () => root.unmount() };
|
| 278 |
+
};
|
| 279 |
+
})();
|
app/templates/home.html
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>MatrixLab Sandbox</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<style>
|
| 9 |
+
body { font-family: 'Inter', system-ui, sans-serif; background: #0a0a0f; color: #e4e4e7; min-height: 100vh; }
|
| 10 |
+
.glass { background: rgba(255,255,255,0.05); backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; }
|
| 11 |
+
.gradient-text { background: linear-gradient(135deg, #06b6d4, #8b5cf6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
| 12 |
+
.status-success { color: #22c55e; } .status-warning { color: #eab308; } .status-error { color: #ef4444; }
|
| 13 |
+
.badge { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 9999px; font-size: 12px; font-weight: 500; }
|
| 14 |
+
pre { font-family: 'JetBrains Mono', monospace; }
|
| 15 |
+
</style>
|
| 16 |
+
</head>
|
| 17 |
+
<body>
|
| 18 |
+
<nav class="sticky top-0 z-50 border-b border-white/5 bg-[#0a0a0f]/80 backdrop-blur-xl">
|
| 19 |
+
<div class="max-w-5xl mx-auto px-6 h-16 flex items-center justify-between">
|
| 20 |
+
<a href="/" class="flex items-center gap-3">
|
| 21 |
+
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-cyan-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">ML</div>
|
| 22 |
+
<span class="gradient-text text-lg font-bold">MatrixLab Sandbox</span>
|
| 23 |
+
</a>
|
| 24 |
+
<a href="/health" class="text-sm text-zinc-500 hover:text-cyan-400">API</a>
|
| 25 |
+
</div>
|
| 26 |
+
</nav>
|
| 27 |
+
|
| 28 |
+
<main class="max-w-5xl mx-auto px-6 py-12">
|
| 29 |
+
{% if error %}
|
| 30 |
+
<div class="mb-6 p-4 rounded-xl bg-red-900/20 border border-red-500/20 text-red-300 text-sm">{{ error }}</div>
|
| 31 |
+
{% endif %}
|
| 32 |
+
|
| 33 |
+
<!-- Upload Section -->
|
| 34 |
+
<div class="text-center mb-12">
|
| 35 |
+
<h1 class="text-4xl font-extrabold mb-4"><span class="gradient-text">Verify Your Agent Project</span></h1>
|
| 36 |
+
<p class="text-zinc-400 text-lg max-w-xl mx-auto mb-8">Upload a generated project ZIP or call the Repo API to run remote containerized checks through MatrixLab Runner.</p>
|
| 37 |
+
|
| 38 |
+
<form action="/upload" method="POST" enctype="multipart/form-data" class="glass p-8 max-w-lg mx-auto">
|
| 39 |
+
<div class="mb-6">
|
| 40 |
+
<label class="block text-sm font-semibold text-zinc-300 mb-2">Project ZIP</label>
|
| 41 |
+
<input type="file" name="file" accept=".zip" required
|
| 42 |
+
class="w-full text-sm text-zinc-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-cyan-500/20 file:text-cyan-400 hover:file:bg-cyan-500/30 cursor-pointer">
|
| 43 |
+
<p class="text-xs text-zinc-500 mt-2">Max 50MB. Accepts ZIP files from agent-generator or any Python/YAML project.</p>
|
| 44 |
+
</div>
|
| 45 |
+
<button type="submit" class="w-full py-3 rounded-xl bg-gradient-to-r from-cyan-500 to-purple-600 text-white font-bold text-sm hover:shadow-lg hover:shadow-cyan-500/20 transition-all">
|
| 46 |
+
Verify Project
|
| 47 |
+
</button>
|
| 48 |
+
</form>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div class="glass p-6 mb-12">
|
| 52 |
+
<h2 class="text-lg font-bold text-zinc-200 mb-3">HF Backend API for GitHub Repos</h2>
|
| 53 |
+
<p class="text-sm text-zinc-400 mb-3">
|
| 54 |
+
This Space can act as a microservice gateway to a MatrixLab Runner at <code class="text-cyan-400">{{ runner_url }}</code>.
|
| 55 |
+
</p>
|
| 56 |
+
<pre class="text-xs text-zinc-400 bg-black/40 rounded-lg p-3 overflow-x-auto">curl -X POST /repo/run \
|
| 57 |
+
-H \"Content-Type: application/json\" \
|
| 58 |
+
-d '{
|
| 59 |
+
\"environment_id\": \"gitpilot-main\",
|
| 60 |
+
\"profile\": \"gitpilot\",
|
| 61 |
+
\"repo_url\": \"https://github.com/ruslanmv/gitpilot\",
|
| 62 |
+
\"branch\": \"main\"
|
| 63 |
+
}'</pre>
|
| 64 |
+
<p class="text-xs text-zinc-500 mt-2">Profiles available: <code>gitpilot</code>, <code>agent-generator</code>, <code>repoguardian</code>, or <code>custom</code> with your own repo URL.</p>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<!-- Verification Pipeline -->
|
| 68 |
+
<div class="glass p-6 mb-12">
|
| 69 |
+
<h2 class="text-lg font-bold text-zinc-200 mb-4">Verification Pipeline</h2>
|
| 70 |
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-center text-sm">
|
| 71 |
+
<div class="p-4 rounded-xl bg-white/5">
|
| 72 |
+
<div class="text-2xl mb-2">📦</div>
|
| 73 |
+
<div class="font-semibold text-zinc-300">Unpack</div>
|
| 74 |
+
<div class="text-zinc-500 text-xs">Extract ZIP contents</div>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="p-4 rounded-xl bg-white/5">
|
| 77 |
+
<div class="text-2xl mb-2">🔍</div>
|
| 78 |
+
<div class="font-semibold text-zinc-300">Detect</div>
|
| 79 |
+
<div class="text-zinc-500 text-xs">Language & framework</div>
|
| 80 |
+
</div>
|
| 81 |
+
<div class="p-4 rounded-xl bg-white/5">
|
| 82 |
+
<div class="text-2xl mb-2">🛡️</div>
|
| 83 |
+
<div class="font-semibold text-zinc-300">Validate</div>
|
| 84 |
+
<div class="text-zinc-500 text-xs">Syntax + security scan</div>
|
| 85 |
+
</div>
|
| 86 |
+
<div class="p-4 rounded-xl bg-white/5">
|
| 87 |
+
<div class="text-2xl mb-2">✅</div>
|
| 88 |
+
<div class="font-semibold text-zinc-300">Test</div>
|
| 89 |
+
<div class="text-zinc-500 text-xs">Import check + pytest</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<!-- Recent Runs -->
|
| 95 |
+
{% if runs %}
|
| 96 |
+
<h2 class="text-lg font-bold text-zinc-200 mb-4">Recent Verifications</h2>
|
| 97 |
+
<div class="space-y-3">
|
| 98 |
+
{% for run in runs %}
|
| 99 |
+
<a href="/runs/{{ run.id }}" class="glass block p-4 hover:border-cyan-500/30 transition-all">
|
| 100 |
+
<div class="flex items-center justify-between">
|
| 101 |
+
<div class="flex items-center gap-3">
|
| 102 |
+
<span class="badge {% if run.status == 'success' %}bg-green-500/10 text-green-400{% elif run.status == 'warning' %}bg-yellow-500/10 text-yellow-400{% else %}bg-red-500/10 text-red-400{% endif %}">{{ run.status }}</span>
|
| 103 |
+
<span class="text-sm font-medium text-zinc-300">{{ run.filename }}</span>
|
| 104 |
+
<span class="text-xs text-zinc-500">{{ run.language }} / {{ run.framework }}</span>
|
| 105 |
+
</div>
|
| 106 |
+
<span class="text-xs text-zinc-600">{{ run.id }}</span>
|
| 107 |
+
</div>
|
| 108 |
+
<p class="text-xs text-zinc-500 mt-1">{{ run.summary }}</p>
|
| 109 |
+
</a>
|
| 110 |
+
{% endfor %}
|
| 111 |
+
</div>
|
| 112 |
+
{% endif %}
|
| 113 |
+
</main>
|
| 114 |
+
|
| 115 |
+
<footer class="border-t border-white/5 py-8 text-center text-zinc-600 text-sm">
|
| 116 |
+
<span class="gradient-text font-semibold">MatrixLab Sandbox</span> v1.0.0 — Agent Verification Service
|
| 117 |
+
</footer>
|
| 118 |
+
</body>
|
| 119 |
+
</html>
|
app/templates/result.html
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Verification Result — MatrixLab</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<style>
|
| 9 |
+
body { font-family: 'Inter', system-ui, sans-serif; background: #0a0a0f; color: #e4e4e7; min-height: 100vh; }
|
| 10 |
+
.glass { background: rgba(255,255,255,0.05); backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; }
|
| 11 |
+
.gradient-text { background: linear-gradient(135deg, #06b6d4, #8b5cf6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
| 12 |
+
.badge { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 9999px; font-size: 12px; font-weight: 500; }
|
| 13 |
+
pre { background: rgba(0,0,0,0.4); border-radius: 8px; padding: 12px; font-size: 12px; overflow-x: auto; font-family: 'JetBrains Mono', monospace; }
|
| 14 |
+
</style>
|
| 15 |
+
</head>
|
| 16 |
+
<body>
|
| 17 |
+
<nav class="sticky top-0 z-50 border-b border-white/5 bg-[#0a0a0f]/80 backdrop-blur-xl">
|
| 18 |
+
<div class="max-w-5xl mx-auto px-6 h-16 flex items-center justify-between">
|
| 19 |
+
<a href="/" class="flex items-center gap-3">
|
| 20 |
+
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-cyan-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">ML</div>
|
| 21 |
+
<span class="gradient-text text-lg font-bold">MatrixLab Sandbox</span>
|
| 22 |
+
</a>
|
| 23 |
+
<a href="/" class="text-sm text-zinc-500 hover:text-cyan-400">New Verification</a>
|
| 24 |
+
</div>
|
| 25 |
+
</nav>
|
| 26 |
+
|
| 27 |
+
<main class="max-w-4xl mx-auto px-6 py-12">
|
| 28 |
+
<!-- Header -->
|
| 29 |
+
<div class="flex items-start justify-between mb-8">
|
| 30 |
+
<div>
|
| 31 |
+
<div class="flex items-center gap-3 mb-2">
|
| 32 |
+
<span class="badge {% if run.status == 'success' %}bg-green-500/10 text-green-400 border border-green-500/20{% elif run.status == 'warning' %}bg-yellow-500/10 text-yellow-400 border border-yellow-500/20{% else %}bg-red-500/10 text-red-400 border border-red-500/20{% endif %}">
|
| 33 |
+
{% if run.status == 'success' %}PASSED{% elif run.status == 'warning' %}WARNINGS{% else %}FAILED{% endif %}
|
| 34 |
+
</span>
|
| 35 |
+
<span class="badge bg-white/5 text-zinc-400 border border-white/10">{{ run.language }}</span>
|
| 36 |
+
<span class="badge bg-white/5 text-zinc-400 border border-white/10">{{ run.framework }}</span>
|
| 37 |
+
</div>
|
| 38 |
+
<h1 class="text-2xl font-bold text-zinc-200">{{ run.filename }}</h1>
|
| 39 |
+
<p class="text-sm text-zinc-500 mt-1">{{ run.summary }}</p>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="text-right text-xs text-zinc-600">
|
| 42 |
+
<div>Run: {{ run.id }}</div>
|
| 43 |
+
<div>{{ run.files_count }} files</div>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<!-- Steps -->
|
| 48 |
+
<div class="space-y-4">
|
| 49 |
+
{% for step in run.steps %}
|
| 50 |
+
<div class="glass p-5">
|
| 51 |
+
<div class="flex items-center justify-between mb-2">
|
| 52 |
+
<div class="flex items-center gap-3">
|
| 53 |
+
{% if step.status == 'success' %}
|
| 54 |
+
<div class="w-6 h-6 rounded-full bg-green-500/20 flex items-center justify-center">
|
| 55 |
+
<svg class="w-3.5 h-3.5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg>
|
| 56 |
+
</div>
|
| 57 |
+
{% elif step.status == 'warning' %}
|
| 58 |
+
<div class="w-6 h-6 rounded-full bg-yellow-500/20 flex items-center justify-center">
|
| 59 |
+
<svg class="w-3.5 h-3.5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
| 60 |
+
</div>
|
| 61 |
+
{% elif step.status == 'error' %}
|
| 62 |
+
<div class="w-6 h-6 rounded-full bg-red-500/20 flex items-center justify-center">
|
| 63 |
+
<svg class="w-3.5 h-3.5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
| 64 |
+
</div>
|
| 65 |
+
{% else %}
|
| 66 |
+
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center">
|
| 67 |
+
<div class="w-2 h-2 rounded-full bg-zinc-500"></div>
|
| 68 |
+
</div>
|
| 69 |
+
{% endif %}
|
| 70 |
+
<span class="font-semibold text-zinc-300 capitalize">{{ step.name }}</span>
|
| 71 |
+
</div>
|
| 72 |
+
<span class="text-xs {% if step.status == 'success' %}text-green-400{% elif step.status == 'warning' %}text-yellow-400{% elif step.status == 'error' %}text-red-400{% else %}text-zinc-500{% endif %}">{{ step.status }}</span>
|
| 73 |
+
</div>
|
| 74 |
+
<p class="text-sm text-zinc-400 ml-9">{{ step.message }}</p>
|
| 75 |
+
{% if step.logs %}
|
| 76 |
+
<pre class="mt-3 ml-9 text-zinc-400">{{ step.logs }}</pre>
|
| 77 |
+
{% endif %}
|
| 78 |
+
</div>
|
| 79 |
+
{% endfor %}
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<div class="mt-8 flex gap-4">
|
| 83 |
+
<a href="/" class="px-6 py-3 rounded-xl bg-gradient-to-r from-cyan-500 to-purple-600 text-white font-bold text-sm">Verify Another</a>
|
| 84 |
+
<a href="/runs/{{ run.id }}" class="px-6 py-3 rounded-xl bg-white/5 border border-white/10 text-zinc-300 font-medium text-sm">API Response</a>
|
| 85 |
+
</div>
|
| 86 |
+
</main>
|
| 87 |
+
</body>
|
| 88 |
+
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.135.0
|
| 2 |
+
uvicorn[standard]>=0.42.0
|
| 3 |
+
jinja2>=3.1.6
|
| 4 |
+
pydantic>=2.12.0
|
| 5 |
+
python-multipart>=0.0.18
|
| 6 |
+
pyyaml>=6.0
|
| 7 |
+
httpx>=0.28.0
|
scripts/sandbox_concurrency_smoke.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Concurrency smoke test for the MatrixLab MCP sandbox worker.
|
| 3 |
+
|
| 4 |
+
Starts N sandbox sessions in parallel against a running HF Space worker,
|
| 5 |
+
waits for each to reach ``running``, asserts tools were discovered, then
|
| 6 |
+
tears them all down. Use it to validate that an instance can sustain the
|
| 7 |
+
configured ``MATRIXLAB_MCP_MAX_SESSIONS`` (e.g. 10) in parallel.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
BASE=http://127.0.0.1:7901 N=10 python hf/scripts/sandbox_concurrency_smoke.py
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
|
| 19 |
+
import httpx
|
| 20 |
+
|
| 21 |
+
BASE = os.environ.get("BASE", "http://127.0.0.1:7901").rstrip("/")
|
| 22 |
+
N = int(os.environ.get("N", "10"))
|
| 23 |
+
TTL = int(os.environ.get("TTL", "120"))
|
| 24 |
+
TOKEN = os.environ.get("MATRIXLAB_SANDBOX_TOKEN", "")
|
| 25 |
+
PLAN = {
|
| 26 |
+
"entity_id": "mcp_server:filesystem",
|
| 27 |
+
"runtime": "node",
|
| 28 |
+
"start_command": "npx -y @modelcontextprotocol/server-filesystem /tmp",
|
| 29 |
+
"transport": "stdio",
|
| 30 |
+
"ttl_seconds": TTL,
|
| 31 |
+
}
|
| 32 |
+
HEADERS = {"Authorization": f"Bearer {TOKEN}"} if TOKEN else {}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def start_one(client: httpx.AsyncClient, i: int) -> dict:
|
| 36 |
+
r = await client.post(f"{BASE}/mcp/sessions", json={**PLAN, "entity_id": f"sbx-{i}"}, headers=HEADERS)
|
| 37 |
+
r.raise_for_status()
|
| 38 |
+
return r.json()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def wait_running(client: httpx.AsyncClient, sid: str, timeout: int = 120) -> dict:
|
| 42 |
+
deadline = time.time() + timeout
|
| 43 |
+
last = {}
|
| 44 |
+
while time.time() < deadline:
|
| 45 |
+
r = await client.get(f"{BASE}/mcp/sessions/{sid}", headers=HEADERS)
|
| 46 |
+
last = r.json()
|
| 47 |
+
st = last.get("status")
|
| 48 |
+
if st in ("running", "failed", "timeout", "install_failed", "start_failed", "mcp_failed"):
|
| 49 |
+
return last
|
| 50 |
+
await asyncio.sleep(1.5)
|
| 51 |
+
return last
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def main() -> int:
|
| 55 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 56 |
+
h = (await client.get(f"{BASE}/mcp/health", headers=HEADERS)).json()
|
| 57 |
+
print(f"worker: max_sessions={h['max_sessions']} instance={h.get('instance')}")
|
| 58 |
+
if h["max_sessions"] < N:
|
| 59 |
+
print(f"NOTE: max_sessions ({h['max_sessions']}) < N ({N}); "
|
| 60 |
+
f"set MATRIXLAB_MCP_MAX_SESSIONS={N} (or 'auto') to admit all.")
|
| 61 |
+
|
| 62 |
+
t0 = time.time()
|
| 63 |
+
started = await asyncio.gather(*[start_one(client, i) for i in range(N)], return_exceptions=True)
|
| 64 |
+
ok_started = [s for s in started if isinstance(s, dict)]
|
| 65 |
+
rejected = [s for s in started if not isinstance(s, dict)]
|
| 66 |
+
print(f"requested {N} · admitted {len(ok_started)} · rejected {len(rejected)} "
|
| 67 |
+
f"in {time.time()-t0:.1f}s")
|
| 68 |
+
|
| 69 |
+
results = await asyncio.gather(*[wait_running(client, s["session_id"]) for s in ok_started])
|
| 70 |
+
running = [r for r in results if r.get("status") == "running"]
|
| 71 |
+
with_tools = [r for r in running if (r.get("tools") or [])]
|
| 72 |
+
elapsed = time.time() - t0
|
| 73 |
+
|
| 74 |
+
print("\nper-session:")
|
| 75 |
+
for r in results:
|
| 76 |
+
print(f" {r.get('entity_id','?'):10} {r.get('status','?'):10} "
|
| 77 |
+
f"tools={len(r.get('tools') or [])}")
|
| 78 |
+
|
| 79 |
+
print(f"\nrunning {len(running)}/{len(ok_started)} · with tools {len(with_tools)} "
|
| 80 |
+
f"· wall {elapsed:.1f}s")
|
| 81 |
+
|
| 82 |
+
# teardown
|
| 83 |
+
await asyncio.gather(*[client.delete(f"{BASE}/mcp/sessions/{s['session_id']}", headers=HEADERS)
|
| 84 |
+
for s in ok_started], return_exceptions=True)
|
| 85 |
+
print("torn down.")
|
| 86 |
+
|
| 87 |
+
ok = len(running) == len(ok_started) and len(with_tools) == len(running) and len(ok_started) >= 1
|
| 88 |
+
print("RESULT:", "PASS" if ok else "FAIL")
|
| 89 |
+
return 0 if ok else 1
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if __name__ == "__main__":
|
| 93 |
+
sys.exit(asyncio.run(main()))
|