Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- AGENTS.md +31 -67
- TASKS.md +3 -1
- app/mcp_policy.py +67 -0
- app/routes/mcp.py +29 -3
- app/routes/user.py +22 -0
- docs/TROUBLESHOOTING.md +1 -0
- static/dashboard.html +29 -0
- static/dashboard.js +78 -0
AGENTS.md
CHANGED
|
@@ -1,85 +1,49 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
|
| 4 |
-
- a Supabase-backed login + user data
|
| 5 |
-
- a chat UI (OpenAI-compatible providers)
|
| 6 |
-
- a PTY-backed web terminal over WebSockets
|
| 7 |
-
- optional Codex CLI/SDK integration
|
| 8 |
-
- optional MCP tooling integration
|
| 9 |
|
| 10 |
-
|
| 11 |
-
-
|
| 12 |
-
-
|
| 13 |
-
-
|
|
|
|
| 14 |
|
| 15 |
-
##
|
| 16 |
-
- `main.py`: FastAPI backend (currently a single large file).
|
| 17 |
-
- `static/index.html`: login page (Supabase Auth).
|
| 18 |
-
- `static/dashboard.html`: main UI (chat, terminal, agent mode, notes, settings UI).
|
| 19 |
-
- `docker-entrypoint.sh`: runtime setup (persistence under `/data`, writes Codex auth files from env if provided).
|
| 20 |
-
- `Dockerfile`: image build (Python + Node + CLIs).
|
| 21 |
-
- `codex_agent.mjs`: Node wrapper around `@openai/codex-sdk`.
|
| 22 |
-
- `.github/workflows/deploy.yml`: deploy to Hugging Face Space.
|
| 23 |
-
- `.github/workflows/codex-autofix.yml`: optional GitHub Action to run Codex for autofixes.
|
| 24 |
|
| 25 |
-
|
| 26 |
-
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
- `
|
| 33 |
-
- `SUPABASE_KEY` (anon key used by the frontend)
|
| 34 |
|
| 35 |
-
##
|
| 36 |
-
- `DEFAULT_BASE_URL` (e.g. `https://router.huggingface.co/v1`)
|
| 37 |
-
- `DEFAULT_API_KEY` (avoid using in production; don’t commit)
|
| 38 |
-
- `DEFAULT_MODEL`
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
-
|
| 43 |
-
- `CODEX_ACCESS_TOKEN` or `ACCESS_TOKEN`
|
| 44 |
-
- `CODEX_REFRESH_TOKEN` or `REFRESH_TOKEN`
|
| 45 |
-
- optional: `CODEX_ACCOUNT_ID` or `ACCOUNT_ID`
|
| 46 |
|
| 47 |
-
|
| 48 |
-
- `~/.codex/.auth.json`
|
| 49 |
-
- `~/.codex/auth.json`
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
-
|
| 54 |
-
-
|
| 55 |
|
| 56 |
-
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
If adding SSH-from-secrets support, prefer:
|
| 61 |
-
- `SSH_PRIVATE_KEY` (+ optional `SSH_PUBLIC_KEY`, `SSH_KNOWN_HOSTS`)
|
| 62 |
-
and ensure files are `0600`, never logged.
|
| 63 |
|
| 64 |
-
## Security
|
| 65 |
-
The web terminal and any agent/Codex/MCP execution is effectively remote code execution if exposed.
|
| 66 |
|
| 67 |
-
|
| 68 |
-
-
|
| 69 |
-
- explicit capability flags (e.g. `ENABLE_TERMINAL`, `ENABLE_CODEX`, `ENABLE_MCP`) with safe defaults
|
| 70 |
-
- rate limiting / abuse controls if public
|
| 71 |
-
|
| 72 |
-
## Development hygiene
|
| 73 |
-
- Prefer extracting modules from `main.py` rather than growing it further.
|
| 74 |
-
- Prefer moving large inline JS/CSS out of `static/dashboard.html` into `static/*.js` + `static/*.css`.
|
| 75 |
-
- Keep UI theme tokens consistent between login and dashboard.
|
| 76 |
-
|
| 77 |
-
## Quick validation
|
| 78 |
-
- Python syntax: `python3 -m py_compile main.py`
|
| 79 |
-
- Basic endpoint check: `curl -sSf http://localhost:7860/health`
|
| 80 |
|
| 81 |
## Deployment notes (HF Spaces)
|
| 82 |
- Port: `7860`
|
| 83 |
- Persistence: `/data` is used for `~/.codex`, `~/.ssh`, and a default workspace directory when available.
|
| 84 |
- Web terminals often require device auth flows; avoid localhost callback assumptions.
|
| 85 |
-
|
|
|
|
| 1 |
+
# Repository Guidelines
|
| 2 |
|
| 3 |
+
## Project Structure & Module Organization
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
- `main.py`: app entrypoint (loads dotenv, creates FastAPI app).
|
| 6 |
+
- `app/`: backend package; feature routers live in `app/routes/` (chat, codex, mcp, terminal, user, admin, rag).
|
| 7 |
+
- `static/`: frontend assets (`landing.html`, `index.html`, `dashboard.html` + `dashboard.js`/`dashboard.css`).
|
| 8 |
+
- `tests/`: pytest suite (security-gate coverage is especially important).
|
| 9 |
+
- `docs/`: architecture and ops notes (start with `docs/ARCHITECTURE.md`).
|
| 10 |
|
| 11 |
+
## Build, Test, and Development Commands
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
```bash
|
| 14 |
+
python -m venv .venv && source .venv/bin/activate
|
| 15 |
+
pip install -r requirements.txt -r requirements-dev.txt
|
| 16 |
+
uvicorn main:app --reload --host 0.0.0.0 --port 7860
|
| 17 |
+
```
|
| 18 |
|
| 19 |
+
- Lint/format: `ruff check .` and `ruff format .`
|
| 20 |
+
- Tests: `pytest -q`
|
| 21 |
+
- Docker (Spaces-like): `docker build -t autonomy-labs . && docker run --rm -p 7860:7860 --env-file .env autonomy-labs`
|
|
|
|
| 22 |
|
| 23 |
+
## Coding Style & Naming Conventions
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
- Python 3.11+, 4-space indentation; prefer explicit types for API payloads and settings.
|
| 26 |
+
- Keep modules small: add new routes under `app/routes/<feature>.py` instead of growing large files.
|
| 27 |
+
- Ruff is the source of truth (line length is 120); fix lint before pushing.
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
## Testing Guidelines
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
- Use `pytest` and keep tests fast and isolated (set env via `monkeypatch`).
|
| 32 |
+
- Name tests `tests/test_<topic>.py` and add coverage for:
|
| 33 |
+
- auth enforcement on dangerous endpoints (`/ws/terminal`, `/api/codex*`, `/api/mcp*`)
|
| 34 |
+
- feature flags (e.g., `ENABLE_CODEX=0` should hard-disable routes).
|
| 35 |
|
| 36 |
+
## Commit & Pull Request Guidelines
|
| 37 |
|
| 38 |
+
- Commits use short, imperative subjects (e.g., “Add RAG document indexing MVP”), no required prefixes.
|
| 39 |
+
- PRs should include: summary, how you tested (`pytest -q`, `ruff check .`), screenshots for UI changes, and any new env vars documented in `.env.example`.
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
## Security & Configuration Tips
|
|
|
|
| 42 |
|
| 43 |
+
- Never commit secrets; use environment variables / Hugging Face Spaces Secrets.
|
| 44 |
+
- High-risk features are gated by Supabase auth and flags (`ENABLE_TERMINAL`, `ENABLE_CODEX`, `ENABLE_MCP`, `ENABLE_INDEXING`). Keep defaults conservative and document changes in `SECURITY.md`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
## Deployment notes (HF Spaces)
|
| 47 |
- Port: `7860`
|
| 48 |
- Persistence: `/data` is used for `~/.codex`, `~/.ssh`, and a default workspace directory when available.
|
| 49 |
- Web terminals often require device auth flows; avoid localhost callback assumptions.
|
|
|
TASKS.md
CHANGED
|
@@ -43,7 +43,9 @@ Legend:
|
|
| 43 |
## P2/P3 — MCP registry
|
| 44 |
- [~] First-class MCP registry storage (per-user persistence via backend).
|
| 45 |
- [~] Admin-managed MCP templates (server-side persisted).
|
| 46 |
-
- [~] “Test connection”
|
|
|
|
|
|
|
| 47 |
- [x] Import/export `mcp.json` via UI with validation.
|
| 48 |
|
| 49 |
## P3 — RAG + indexing (docs/web/GitHub) + “password manager”
|
|
|
|
| 43 |
## P2/P3 — MCP registry
|
| 44 |
- [~] First-class MCP registry storage (per-user persistence via backend).
|
| 45 |
- [~] Admin-managed MCP templates (server-side persisted).
|
| 46 |
+
- [~] “Test connection” (browser/CORS; SSRF-safe server proxy pending).
|
| 47 |
+
- [x] “List tools” (via `/api/mcp/tools`).
|
| 48 |
+
- [x] Tool allow/deny policy (UI + server-side enforcement).
|
| 49 |
- [x] Import/export `mcp.json` via UI with validation.
|
| 50 |
|
| 51 |
## P3 — RAG + indexing (docs/web/GitHub) + “password manager”
|
app/mcp_policy.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from fastapi import HTTPException
|
| 8 |
+
|
| 9 |
+
from app.storage import user_data_dir
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _policy_path(user_id: str) -> Path:
|
| 13 |
+
return user_data_dir(user_id) / "mcp-policy.json"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def load_mcp_policy(user_id: str) -> dict[str, Any]:
|
| 17 |
+
path = _policy_path(user_id)
|
| 18 |
+
try:
|
| 19 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 20 |
+
if not isinstance(data, dict):
|
| 21 |
+
raise ValueError("Invalid policy")
|
| 22 |
+
allow = data.get("allow")
|
| 23 |
+
deny = data.get("deny")
|
| 24 |
+
return {
|
| 25 |
+
"version": int(data.get("version") or 1),
|
| 26 |
+
"allow": allow if isinstance(allow, list) else [],
|
| 27 |
+
"deny": deny if isinstance(deny, list) else [],
|
| 28 |
+
}
|
| 29 |
+
except FileNotFoundError:
|
| 30 |
+
return {"version": 1, "allow": [], "deny": []}
|
| 31 |
+
except Exception as e:
|
| 32 |
+
raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def save_mcp_policy(user_id: str, policy: dict[str, Any]) -> dict[str, Any]:
|
| 36 |
+
allow_in = policy.get("allow")
|
| 37 |
+
deny_in = policy.get("deny")
|
| 38 |
+
|
| 39 |
+
allow = [str(x).strip() for x in (allow_in or []) if str(x).strip()] if isinstance(allow_in, list) else []
|
| 40 |
+
deny = [str(x).strip() for x in (deny_in or []) if str(x).strip()] if isinstance(deny_in, list) else []
|
| 41 |
+
|
| 42 |
+
allow = list(dict.fromkeys(allow))[:500]
|
| 43 |
+
deny = list(dict.fromkeys(deny))[:500]
|
| 44 |
+
|
| 45 |
+
payload = {"version": int(policy.get("version") or 1), "allow": allow, "deny": deny}
|
| 46 |
+
path = _policy_path(user_id)
|
| 47 |
+
try:
|
| 48 |
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
| 49 |
+
return payload
|
| 50 |
+
except Exception as e:
|
| 51 |
+
raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def tool_allowed(tool_name: str, policy: dict[str, Any]) -> bool:
|
| 55 |
+
name = (tool_name or "").strip()
|
| 56 |
+
if not name:
|
| 57 |
+
return False
|
| 58 |
+
deny = policy.get("deny") if isinstance(policy.get("deny"), list) else []
|
| 59 |
+
allow = policy.get("allow") if isinstance(policy.get("allow"), list) else []
|
| 60 |
+
deny_set = {str(x).strip() for x in deny if str(x).strip()}
|
| 61 |
+
allow_set = {str(x).strip() for x in allow if str(x).strip()}
|
| 62 |
+
if name in deny_set:
|
| 63 |
+
return False
|
| 64 |
+
if allow_set and name not in allow_set:
|
| 65 |
+
return False
|
| 66 |
+
return True
|
| 67 |
+
|
app/routes/mcp.py
CHANGED
|
@@ -4,6 +4,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
| 4 |
from pydantic import BaseModel
|
| 5 |
|
| 6 |
from app.auth import require_user_from_request
|
|
|
|
| 7 |
from app.settings import feature_enabled
|
| 8 |
|
| 9 |
router = APIRouter()
|
|
@@ -13,10 +14,26 @@ router = APIRouter()
|
|
| 13 |
async def mcp_tools_list(http_request: Request):
|
| 14 |
if not feature_enabled("mcp"):
|
| 15 |
raise HTTPException(status_code=403, detail={"code": "feature_disabled", "message": "MCP is disabled"})
|
| 16 |
-
|
|
|
|
| 17 |
try:
|
|
|
|
| 18 |
result = await http_request.app.state.codex_mcp_client.list_tools()
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
except Exception as e:
|
| 21 |
raise HTTPException(status_code=500, detail={"code": "mcp_error", "message": str(e)}) from e
|
| 22 |
|
|
@@ -30,8 +47,17 @@ class McpCallRequest(BaseModel):
|
|
| 30 |
async def mcp_tools_call(request: McpCallRequest, http_request: Request):
|
| 31 |
if not feature_enabled("mcp"):
|
| 32 |
raise HTTPException(status_code=403, detail={"code": "feature_disabled", "message": "MCP is disabled"})
|
| 33 |
-
|
|
|
|
| 34 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
return await http_request.app.state.codex_mcp_client.call_tool(request.name, request.arguments)
|
|
|
|
|
|
|
| 36 |
except Exception as e:
|
| 37 |
raise HTTPException(status_code=500, detail={"code": "mcp_error", "message": str(e)}) from e
|
|
|
|
| 4 |
from pydantic import BaseModel
|
| 5 |
|
| 6 |
from app.auth import require_user_from_request
|
| 7 |
+
from app.mcp_policy import load_mcp_policy, tool_allowed
|
| 8 |
from app.settings import feature_enabled
|
| 9 |
|
| 10 |
router = APIRouter()
|
|
|
|
| 14 |
async def mcp_tools_list(http_request: Request):
|
| 15 |
if not feature_enabled("mcp"):
|
| 16 |
raise HTTPException(status_code=403, detail={"code": "feature_disabled", "message": "MCP is disabled"})
|
| 17 |
+
user = await require_user_from_request(http_request)
|
| 18 |
+
user_id = str(user.get("id") or "")
|
| 19 |
try:
|
| 20 |
+
policy = load_mcp_policy(user_id)
|
| 21 |
result = await http_request.app.state.codex_mcp_client.list_tools()
|
| 22 |
+
tools = None
|
| 23 |
+
if isinstance(result, dict) and isinstance(result.get("tools"), list):
|
| 24 |
+
tools = result.get("tools")
|
| 25 |
+
if tools is None:
|
| 26 |
+
return result
|
| 27 |
+
filtered = []
|
| 28 |
+
for t in tools:
|
| 29 |
+
if not isinstance(t, dict):
|
| 30 |
+
continue
|
| 31 |
+
name = str(t.get("name") or "").strip()
|
| 32 |
+
if tool_allowed(name, policy):
|
| 33 |
+
filtered.append(t)
|
| 34 |
+
return {**result, "tools": filtered}
|
| 35 |
+
except HTTPException:
|
| 36 |
+
raise
|
| 37 |
except Exception as e:
|
| 38 |
raise HTTPException(status_code=500, detail={"code": "mcp_error", "message": str(e)}) from e
|
| 39 |
|
|
|
|
| 47 |
async def mcp_tools_call(request: McpCallRequest, http_request: Request):
|
| 48 |
if not feature_enabled("mcp"):
|
| 49 |
raise HTTPException(status_code=403, detail={"code": "feature_disabled", "message": "MCP is disabled"})
|
| 50 |
+
user = await require_user_from_request(http_request)
|
| 51 |
+
user_id = str(user.get("id") or "")
|
| 52 |
try:
|
| 53 |
+
policy = load_mcp_policy(user_id)
|
| 54 |
+
if not tool_allowed(request.name, policy):
|
| 55 |
+
raise HTTPException(
|
| 56 |
+
status_code=403,
|
| 57 |
+
detail={"code": "tool_denied", "message": f"MCP tool not allowed: {request.name}"},
|
| 58 |
+
)
|
| 59 |
return await http_request.app.state.codex_mcp_client.call_tool(request.name, request.arguments)
|
| 60 |
+
except HTTPException:
|
| 61 |
+
raise
|
| 62 |
except Exception as e:
|
| 63 |
raise HTTPException(status_code=500, detail={"code": "mcp_error", "message": str(e)}) from e
|
app/routes/user.py
CHANGED
|
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
from app.auth import require_user_from_request
|
|
|
|
| 11 |
from app.settings import feature_enabled
|
| 12 |
from app.storage import user_data_dir
|
| 13 |
|
|
@@ -93,3 +94,24 @@ async def put_mcp_registry(body: McpRegistry, http_request: Request):
|
|
| 93 |
return {"ok": True, "count": len(servers)}
|
| 94 |
except Exception as e:
|
| 95 |
raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
from app.auth import require_user_from_request
|
| 11 |
+
from app.mcp_policy import load_mcp_policy, save_mcp_policy
|
| 12 |
from app.settings import feature_enabled
|
| 13 |
from app.storage import user_data_dir
|
| 14 |
|
|
|
|
| 94 |
return {"ok": True, "count": len(servers)}
|
| 95 |
except Exception as e:
|
| 96 |
raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class McpPolicy(BaseModel):
|
| 100 |
+
version: int = 1
|
| 101 |
+
allow: list[str] = []
|
| 102 |
+
deny: list[str] = []
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.get("/api/user/mcp-policy")
|
| 106 |
+
async def get_mcp_policy(http_request: Request):
|
| 107 |
+
user = await require_user_from_request(http_request)
|
| 108 |
+
user_id = str(user.get("id") or "")
|
| 109 |
+
return load_mcp_policy(user_id)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@router.put("/api/user/mcp-policy")
|
| 113 |
+
async def put_mcp_policy(body: McpPolicy, http_request: Request):
|
| 114 |
+
user = await require_user_from_request(http_request)
|
| 115 |
+
user_id = str(user.get("id") or "")
|
| 116 |
+
saved = save_mcp_policy(user_id, body.model_dump())
|
| 117 |
+
return {"ok": True, "policy": saved}
|
docs/TROUBLESHOOTING.md
CHANGED
|
@@ -37,6 +37,7 @@ The Settings → MCP “Test” button runs from your browser, so it is subject
|
|
| 37 |
|
| 38 |
Also note:
|
| 39 |
- `mcp.json` import only accepts `http://` / `https://` URLs.
|
|
|
|
| 40 |
|
| 41 |
## API errors are shown as JSON
|
| 42 |
|
|
|
|
| 37 |
|
| 38 |
Also note:
|
| 39 |
- `mcp.json` import only accepts `http://` / `https://` URLs.
|
| 40 |
+
- If MCP tool calls are blocked, check Settings → MCP → Tool Policy (server-enforced allow/deny list).
|
| 41 |
|
| 42 |
## API errors are shown as JSON
|
| 43 |
|
static/dashboard.html
CHANGED
|
@@ -384,6 +384,35 @@
|
|
| 384 |
<button onclick="saveMcpRegistryToServer()"
|
| 385 |
class="bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded text-xs">Save (server)</button>
|
| 386 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
<div class="flex gap-2 mb-3">
|
| 388 |
<input id="mcp-direct-url" type="text" placeholder="Direct MCP URL (https://...)"
|
| 389 |
class="flex-grow bg-gray-700 text-sm rounded border border-gray-600 p-2 text-white outline-none focus:border-blue-500">
|
|
|
|
| 384 |
<button onclick="saveMcpRegistryToServer()"
|
| 385 |
class="bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded text-xs">Save (server)</button>
|
| 386 |
</div>
|
| 387 |
+
<div class="bg-gray-900/30 border border-gray-700 rounded-lg p-3 mb-3 space-y-2">
|
| 388 |
+
<div class="flex items-center justify-between gap-2">
|
| 389 |
+
<div class="text-xs font-semibold text-gray-300 uppercase">Tool Policy</div>
|
| 390 |
+
<div class="flex gap-2">
|
| 391 |
+
<button onclick="loadMcpPolicyFromServer()"
|
| 392 |
+
class="bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded text-xs">Load</button>
|
| 393 |
+
<button onclick="saveMcpPolicyToServer()"
|
| 394 |
+
class="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded text-xs">Save</button>
|
| 395 |
+
</div>
|
| 396 |
+
</div>
|
| 397 |
+
<div class="text-xs text-gray-400">Optional allow/deny list enforced server-side for <code>/api/mcp/call</code> and tool listing.</div>
|
| 398 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
| 399 |
+
<div>
|
| 400 |
+
<label class="block text-xs font-semibold text-gray-400 mb-1 uppercase">Allow tools (one per line)</label>
|
| 401 |
+
<textarea id="mcp-allow-tools" rows="3"
|
| 402 |
+
class="w-full bg-gray-700 text-sm rounded border border-gray-600 p-2 text-white outline-none focus:border-blue-500 font-mono"
|
| 403 |
+
placeholder="filesystem.read\nfilesystem.write"></textarea>
|
| 404 |
+
<div class="text-xs text-gray-500 mt-1">If non-empty, only these tools are allowed.</div>
|
| 405 |
+
</div>
|
| 406 |
+
<div>
|
| 407 |
+
<label class="block text-xs font-semibold text-gray-400 mb-1 uppercase">Deny tools (one per line)</label>
|
| 408 |
+
<textarea id="mcp-deny-tools" rows="3"
|
| 409 |
+
class="w-full bg-gray-700 text-sm rounded border border-gray-600 p-2 text-white outline-none focus:border-blue-500 font-mono"
|
| 410 |
+
placeholder="filesystem.write"></textarea>
|
| 411 |
+
<div class="text-xs text-gray-500 mt-1">Always blocked (overrides allow).</div>
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
<div id="mcp-policy-status" class="text-xs text-gray-500"></div>
|
| 415 |
+
</div>
|
| 416 |
<div class="flex gap-2 mb-3">
|
| 417 |
<input id="mcp-direct-url" type="text" placeholder="Direct MCP URL (https://...)"
|
| 418 |
class="flex-grow bg-gray-700 text-sm rounded border border-gray-600 p-2 text-white outline-none focus:border-blue-500">
|
static/dashboard.js
CHANGED
|
@@ -659,6 +659,8 @@ let supabase;
|
|
| 659 |
initProviderPresets();
|
| 660 |
// Best-effort: hydrate server-persisted MCP registry if local storage is empty.
|
| 661 |
maybeHydrateMcpRegistryFromServer();
|
|
|
|
|
|
|
| 662 |
// Restore last chat mode (chat/autonomous)
|
| 663 |
const savedMode = localStorage.getItem('chat_mode_v1') || 'chat';
|
| 664 |
setChatMode(savedMode);
|
|
@@ -2144,6 +2146,7 @@ let supabase;
|
|
| 2144 |
|
| 2145 |
// --- MCP Admin ---
|
| 2146 |
const MCP_STORAGE_KEY = 'mcp_servers_v1';
|
|
|
|
| 2147 |
|
| 2148 |
async function maybeHydrateMcpRegistryFromServer() {
|
| 2149 |
try {
|
|
@@ -2153,6 +2156,81 @@ let supabase;
|
|
| 2153 |
} catch { }
|
| 2154 |
}
|
| 2155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2156 |
async function loadMcpRegistryFromServer({ quiet = false } = {}) {
|
| 2157 |
const statusEl = document.getElementById('codex-mcp-list');
|
| 2158 |
try {
|
|
|
|
| 659 |
initProviderPresets();
|
| 660 |
// Best-effort: hydrate server-persisted MCP registry if local storage is empty.
|
| 661 |
maybeHydrateMcpRegistryFromServer();
|
| 662 |
+
writeMcpPolicyToInputs(loadMcpPolicyFromLocalStorage());
|
| 663 |
+
maybeHydrateMcpPolicyFromServer();
|
| 664 |
// Restore last chat mode (chat/autonomous)
|
| 665 |
const savedMode = localStorage.getItem('chat_mode_v1') || 'chat';
|
| 666 |
setChatMode(savedMode);
|
|
|
|
| 2146 |
|
| 2147 |
// --- MCP Admin ---
|
| 2148 |
const MCP_STORAGE_KEY = 'mcp_servers_v1';
|
| 2149 |
+
const MCP_POLICY_STORAGE_KEY = 'mcp_policy_v1';
|
| 2150 |
|
| 2151 |
async function maybeHydrateMcpRegistryFromServer() {
|
| 2152 |
try {
|
|
|
|
| 2156 |
} catch { }
|
| 2157 |
}
|
| 2158 |
|
| 2159 |
+
async function maybeHydrateMcpPolicyFromServer() {
|
| 2160 |
+
try {
|
| 2161 |
+
const raw = localStorage.getItem(MCP_POLICY_STORAGE_KEY);
|
| 2162 |
+
if (raw && raw.trim() && raw.trim() !== '{}' && raw.trim() !== 'null') return;
|
| 2163 |
+
await loadMcpPolicyFromServer({ quiet: true });
|
| 2164 |
+
} catch { }
|
| 2165 |
+
}
|
| 2166 |
+
|
| 2167 |
+
function setMcpPolicyStatus(text) {
|
| 2168 |
+
const el = document.getElementById('mcp-policy-status');
|
| 2169 |
+
if (el) el.textContent = text || '';
|
| 2170 |
+
}
|
| 2171 |
+
|
| 2172 |
+
function readMcpPolicyFromInputs() {
|
| 2173 |
+
const allowEl = document.getElementById('mcp-allow-tools');
|
| 2174 |
+
const denyEl = document.getElementById('mcp-deny-tools');
|
| 2175 |
+
const splitLines = (v) => (v || '').split('\n').map((s) => s.trim()).filter(Boolean);
|
| 2176 |
+
const allow = splitLines(allowEl?.value || '');
|
| 2177 |
+
const deny = splitLines(denyEl?.value || '');
|
| 2178 |
+
return { version: 1, allow, deny };
|
| 2179 |
+
}
|
| 2180 |
+
|
| 2181 |
+
function writeMcpPolicyToInputs(policy) {
|
| 2182 |
+
const allowEl = document.getElementById('mcp-allow-tools');
|
| 2183 |
+
const denyEl = document.getElementById('mcp-deny-tools');
|
| 2184 |
+
const allow = Array.isArray(policy?.allow) ? policy.allow : [];
|
| 2185 |
+
const deny = Array.isArray(policy?.deny) ? policy.deny : [];
|
| 2186 |
+
if (allowEl) allowEl.value = allow.join('\n');
|
| 2187 |
+
if (denyEl) denyEl.value = deny.join('\n');
|
| 2188 |
+
}
|
| 2189 |
+
|
| 2190 |
+
function loadMcpPolicyFromLocalStorage() {
|
| 2191 |
+
try {
|
| 2192 |
+
return JSON.parse(localStorage.getItem(MCP_POLICY_STORAGE_KEY) || '{}');
|
| 2193 |
+
} catch {
|
| 2194 |
+
return {};
|
| 2195 |
+
}
|
| 2196 |
+
}
|
| 2197 |
+
|
| 2198 |
+
function saveMcpPolicyToLocalStorage(policy) {
|
| 2199 |
+
localStorage.setItem(MCP_POLICY_STORAGE_KEY, JSON.stringify(policy || {}));
|
| 2200 |
+
}
|
| 2201 |
+
|
| 2202 |
+
async function loadMcpPolicyFromServer({ quiet = false } = {}) {
|
| 2203 |
+
try {
|
| 2204 |
+
if (!quiet) setMcpPolicyStatus('Loading policy from server...');
|
| 2205 |
+
const res = await authFetch('/api/user/mcp-policy');
|
| 2206 |
+
if (!res.ok) throw new Error(await res.text());
|
| 2207 |
+
const data = await res.json();
|
| 2208 |
+
saveMcpPolicyToLocalStorage(data);
|
| 2209 |
+
writeMcpPolicyToInputs(data);
|
| 2210 |
+
if (!quiet) setMcpPolicyStatus('Loaded policy from server.');
|
| 2211 |
+
} catch (e) {
|
| 2212 |
+
if (!quiet) setMcpPolicyStatus(`Failed to load policy: ${e?.message || e}`);
|
| 2213 |
+
}
|
| 2214 |
+
}
|
| 2215 |
+
|
| 2216 |
+
async function saveMcpPolicyToServer() {
|
| 2217 |
+
try {
|
| 2218 |
+
setMcpPolicyStatus('Saving policy to server...');
|
| 2219 |
+
const policy = readMcpPolicyFromInputs();
|
| 2220 |
+
const res = await authFetch('/api/user/mcp-policy', {
|
| 2221 |
+
method: 'PUT',
|
| 2222 |
+
headers: { 'Content-Type': 'application/json' },
|
| 2223 |
+
body: JSON.stringify(policy),
|
| 2224 |
+
});
|
| 2225 |
+
if (!res.ok) throw new Error(await res.text());
|
| 2226 |
+
const data = await res.json();
|
| 2227 |
+
saveMcpPolicyToLocalStorage(data?.policy || policy);
|
| 2228 |
+
setMcpPolicyStatus('Saved policy to server.');
|
| 2229 |
+
} catch (e) {
|
| 2230 |
+
setMcpPolicyStatus(`Failed to save policy: ${e?.message || e}`);
|
| 2231 |
+
}
|
| 2232 |
+
}
|
| 2233 |
+
|
| 2234 |
async function loadMcpRegistryFromServer({ quiet = false } = {}) {
|
| 2235 |
const statusEl = document.getElementById('codex-mcp-list');
|
| 2236 |
try {
|