github-actions[bot] commited on
Commit
89d0fd3
·
0 Parent(s):

Deploy from 2528090f

Browse files
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" }}>&gt;</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()))